b4c405a127
Der erste Live-Lauf der Lucy-Annahme (wartung/pc-annahme-und-turncheck-aus) fand zwei Bugs im Box-Runner — der Build selbst war gruen, nur Start und Abschluss mussten manuell nachgezogen werden: 1) Start-Process -ArgumentList quotet Elemente NICHT (PS 5.1): der Runner-Pfad 'F:\Coding Stuff\lucy\...' zerbrach am Leerzeichen, die gespawnte powershell starb still (kein Status, kein Log). Fix: Anfuehrungszeichen ins -File-Element eingebettet. (Der Probe-Test lief ueber einen Pfad OHNE Leerzeichen — deshalb nicht gefangen.) 2) Poll-Parser: \" in f-String-Ausdruecken ist seit Python 3.12 ein SyntaxError (Box: 3.14) -> der Poll blieb blind und haette den GRUENEN Merge nach 25 min revertiert. Fix: keine f-String-Quote-Akrobatik mehr, Werte in Variablen. Neuer Parser 1:1 auf der Box getestet. 3) executor.py /shell: CREATE_NO_WINDOW — der Executor laeuft unter pythonw, jede gespawnte powershell bekam ein SICHTBARES Konsolenfenster (Polling blitzte im 10-s-Takt auf dem Desktop). Nach Annahme: PC-Checkout pullen + HermesPCExecutor-Task neu starten (mache ich, steht auch in FALLEN.md). Beide Fallen + Fensterblitz in docs/wissen/FALLEN.md; OFFENE-FAEDEN: S3-Stand. Geprueft: bash -n gruen, py_compile gruen, Parser auf Box-Python 3.14 verifiziert.
212 lines
8.4 KiB
Python
212 lines
8.4 KiB
Python
"""
|
|
Hermes PC Executor — läuft auf dem Windows PC.
|
|
Empfängt Tool-Befehle vom MCP-Server der AI Box und führt sie lokal aus.
|
|
"""
|
|
import base64
|
|
import hmac
|
|
import io
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import webbrowser
|
|
from urllib.parse import quote
|
|
|
|
import uvicorn
|
|
from fastapi import Depends, FastAPI, Header, HTTPException
|
|
|
|
app = FastAPI(title="Hermes PC Executor")
|
|
|
|
MY_PORT = int(os.environ.get("HERMES_PC_PORT", "7777"))
|
|
# Auf welchem Interface lauschen. Default 0.0.0.0 (LAN), per Env einschränkbar (z.B. die LAN-IP des PCs).
|
|
MY_HOST = os.environ.get("HERMES_PC_HOST", "0.0.0.0")
|
|
|
|
# ── Auth ───────────────────────────────────────────────────────────────────
|
|
# Shared Secret. OHNE Token sind die gefährlichen Endpunkte (Shell/Eingabe/Öffnen/Screenshot)
|
|
# fail-closed gesperrt → ein un-konfigurierter Executor ist KEINE offene Remote-Code-Execution mehr.
|
|
# Der Token muss identisch auf der Box (Hermes-Env PC_EXECUTOR_TOKEN → mcp_pc.py) gesetzt sein.
|
|
AUTH_TOKEN = os.environ.get("HERMES_PC_TOKEN", "").strip()
|
|
|
|
|
|
def require_auth(authorization: str | None = Header(default=None)) -> None:
|
|
"""Bearer-Token-Prüfung (konstante Zeit). 503 wenn der Executor ohne Token läuft (fail-closed),
|
|
401 bei fehlendem/falschem Token."""
|
|
if not AUTH_TOKEN:
|
|
raise HTTPException(
|
|
503,
|
|
"Executor ohne HERMES_PC_TOKEN gestartet — Steuer-Endpunkte sind aus Sicherheitsgründen "
|
|
"gesperrt. Setze die Env-Variable HERMES_PC_TOKEN (identisch zur Box) und starte neu.",
|
|
)
|
|
expected = f"Bearer {AUTH_TOKEN}"
|
|
if not authorization or not hmac.compare_digest(authorization, expected):
|
|
raise HTTPException(401, "Ungültiges oder fehlendes Bearer-Token.")
|
|
|
|
|
|
# ── Shell ──────────────────────────────────────────────────────────────────
|
|
|
|
@app.post("/shell", dependencies=[Depends(require_auth)])
|
|
async def run_shell(req: dict):
|
|
cmd = req.get("command", "")
|
|
try:
|
|
result = subprocess.run(
|
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
|
|
capture_output=True, text=True, timeout=60,
|
|
encoding="utf-8", errors="replace",
|
|
# Der Executor läuft unter pythonw (kein eigenes Konsolenfenster) — ohne dieses
|
|
# Flag bekäme JEDE gespawnte powershell ein sichtbares Fenster (beim 10-s-Polling
|
|
# der Lucy-Annahme blitzte das im Sekundentakt auf dem Desktop, 12.07.).
|
|
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
)
|
|
return {
|
|
"stdout": result.stdout.strip(),
|
|
"stderr": result.stderr.strip(),
|
|
"returncode": result.returncode,
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
raise HTTPException(408, "Timeout nach 60s")
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
# ── Screen ─────────────────────────────────────────────────────────────────
|
|
|
|
@app.post("/screenshot", dependencies=[Depends(require_auth)])
|
|
async def take_screenshot():
|
|
try:
|
|
import mss
|
|
from PIL import Image
|
|
|
|
with mss.mss() as sct:
|
|
monitor = sct.monitors[1]
|
|
raw = sct.grab(monitor)
|
|
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
|
|
img.thumbnail((1280, 720))
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="JPEG", quality=75)
|
|
return {"screenshot": base64.b64encode(buf.getvalue()).decode()}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
# ── Input ──────────────────────────────────────────────────────────────────
|
|
|
|
@app.post("/type", dependencies=[Depends(require_auth)])
|
|
async def type_text(req: dict):
|
|
text = req.get("text", "")
|
|
try:
|
|
import pyautogui
|
|
pyautogui.write(text, interval=0.03)
|
|
return {"ok": True}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/key", dependencies=[Depends(require_auth)])
|
|
async def press_keys(req: dict):
|
|
keys = req.get("keys", "")
|
|
try:
|
|
import pyautogui
|
|
parts = [k.strip() for k in keys.split("+")]
|
|
pyautogui.hotkey(*parts)
|
|
return {"ok": True}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
# ── Medien & Lautstärke (A3: Lucy steuert den PC per Sprache) ──────────────
|
|
# Media-Keys wirken auf den AKTIVEN Player (Spotify, YouTube, VLC …) — genau wie
|
|
# die Tasten auf einer Multimedia-Tastatur. Lautstärke = System-Master (2 % je Schritt).
|
|
MEDIA_KEYS = {"play_pause": "playpause", "next": "nexttrack", "prev": "prevtrack", "stop": "stop"}
|
|
VOLUME_KEYS = {"up": "volumeup", "down": "volumedown", "mute": "volumemute"}
|
|
|
|
|
|
@app.post("/media", dependencies=[Depends(require_auth)])
|
|
async def media_control(req: dict):
|
|
action = (req.get("action") or "").strip().lower()
|
|
key = MEDIA_KEYS.get(action)
|
|
if not key:
|
|
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(MEDIA_KEYS)}")
|
|
try:
|
|
import pyautogui
|
|
pyautogui.press(key)
|
|
return {"ok": True, "action": action}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/volume", dependencies=[Depends(require_auth)])
|
|
async def volume_control(req: dict):
|
|
action = (req.get("action") or "").strip().lower()
|
|
key = VOLUME_KEYS.get(action)
|
|
if not key:
|
|
raise HTTPException(400, f"Unbekannte Aktion '{action}'. Erlaubt: {', '.join(VOLUME_KEYS)}")
|
|
steps = 1 if action == "mute" else max(1, min(25, int(req.get("steps", 5))))
|
|
try:
|
|
import pyautogui
|
|
pyautogui.press(key, presses=steps, interval=0.02)
|
|
return {"ok": True, "action": action, "steps": steps}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
# ── Apps / Browser ─────────────────────────────────────────────────────────
|
|
|
|
@app.post("/open", dependencies=[Depends(require_auth)])
|
|
async def open_target(req: dict):
|
|
target = req.get("target", "")
|
|
try:
|
|
if target.startswith("http://") or target.startswith("https://"):
|
|
webbrowser.open(target)
|
|
else:
|
|
os.startfile(target)
|
|
return {"ok": True}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/search", dependencies=[Depends(require_auth)])
|
|
async def search_web(req: dict):
|
|
query = req.get("query", "")
|
|
url = f"https://www.google.com/search?q={quote(query)}"
|
|
webbrowser.open(url)
|
|
return {"ok": True, "url": url}
|
|
|
|
|
|
# ── Health ─────────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok", "host": socket.gethostname()}
|
|
|
|
|
|
def _get_local_ip() -> str:
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
s.connect(("8.8.8.8", 80))
|
|
return s.getsockname()[0]
|
|
except Exception:
|
|
return socket.gethostbyname(socket.gethostname())
|
|
|
|
|
|
def _ensure_streams() -> None:
|
|
"""Unter pythonw (kein Konsolenfenster) sind sys.stdout/stderr = None →
|
|
uvicorns Logging crasht beim Start. Dann auf eine Logdatei umbiegen."""
|
|
import sys
|
|
if sys.stdout is not None and sys.stderr is not None:
|
|
return
|
|
logdir = os.path.join(
|
|
os.environ.get("LOCALAPPDATA", os.path.dirname(os.path.abspath(__file__))),
|
|
"HermesPCExecutor",
|
|
)
|
|
os.makedirs(logdir, exist_ok=True)
|
|
f = open(os.path.join(logdir, "executor.log"), "a", buffering=1, encoding="utf-8")
|
|
sys.stdout = f
|
|
sys.stderr = f
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_ensure_streams()
|
|
my_ip = _get_local_ip()
|
|
auth_state = "Token AKTIV" if AUTH_TOKEN else "KEIN Token → Steuer-Endpunkte GESPERRT (fail-closed)"
|
|
print(f"Hermes PC Executor läuft auf http://{my_ip}:{MY_PORT} (bind {MY_HOST}) — {auth_state}")
|
|
uvicorn.run(app, host=MY_HOST, port=MY_PORT)
|