#!/usr/bin/env python3 """hermes-exec — exécuteur d'actions Hermes pour l'assistant vocal STT. Petit service HTTP (tourne en utilisateur `hermes` sur storage-01) qui lance le vrai agent Hermes en one-shot : `hermes -z "" --yolo` (profil par défaut, tous ses outils). Appelé par le STT-server **après** confirmation vocale de l'utilisateur. Sécurité : • Jeton d'auth OBLIGATOIRE (Bearer) — comparaison à temps constant. Sans lui, refus. C'est ce qui empêche n'importe quel client du cluster de déclencher l'agent. • `--yolo` (auto-exécution) n'est atteint qu'avec un jeton valide ; la confirmation qui protège l'utilisateur des erreurs de transcription est faite EN AMONT (STT-server). • Audit : chaque requête (prompt + sortie) est journalisée. Endpoints : GET /health → {"ok": true} (sans auth) POST /exec {prompt} → {"output": …} (Bearer requis) Config via variables d'environnement (cf. rôle Ansible hermes_exec) : HERMES_EXEC_TOKEN jeton attendu (obligatoire ; vide → service refuse tout) HERMES_EXEC_PORT port d'écoute (défaut 9096) HERMES_BIN chemin du binaire hermes (défaut "hermes") HERMES_EXEC_TIMEOUT timeout d'une action en secondes (défaut 120) """ import hmac import json import os import subprocess import threading from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path TOKEN = os.getenv("HERMES_EXEC_TOKEN", "") PORT = int(os.getenv("HERMES_EXEC_PORT", "9096")) HERMES_BIN = os.getenv("HERMES_BIN", "hermes") TIMEOUT = int(os.getenv("HERMES_EXEC_TIMEOUT", "120")) LOG_FILE = Path(os.getenv("HERMES_EXEC_LOG", "/var/log/hermes-exec.log")) # Une action à la fois : l'agent + ses outils ne doivent pas s'entrelacer. _lock = threading.Lock() def _audit(prompt: str, status: str, output: str) -> None: try: LOG_FILE.parent.mkdir(parents=True, exist_ok=True) with open(LOG_FILE, "a") as f: f.write(f"\n=== {datetime.now(timezone.utc).isoformat()} [{status}] ===\n") f.write(f"PROMPT: {prompt}\n") f.write(f"OUTPUT: {output[:4000]}\n") except Exception: # noqa: BLE001 — l'audit ne doit jamais casser l'exécution pass def run_hermes(prompt: str) -> dict: """Lance `hermes -z "" --yolo` et renvoie {ok, output}.""" if not _lock.acquire(blocking=False): return {"ok": False, "output": "Une action est déjà en cours, réessaie dans un instant."} try: proc = subprocess.run( [HERMES_BIN, "-z", prompt, "--yolo"], capture_output=True, text=True, timeout=TIMEOUT, ) out = (proc.stdout or "").strip() or (proc.stderr or "").strip() _audit(prompt, "ok" if proc.returncode == 0 else f"rc={proc.returncode}", out) return {"ok": proc.returncode == 0, "output": out or "(aucune sortie)"} except subprocess.TimeoutExpired: _audit(prompt, "timeout", "") return {"ok": False, "output": f"Action interrompue (dépassement de {TIMEOUT}s)."} except Exception as e: # noqa: BLE001 _audit(prompt, "error", str(e)) return {"ok": False, "output": f"Erreur d'exécution : {e}"} finally: _lock.release() class Handler(BaseHTTPRequestHandler): def log_message(self, *a): # silence le log HTTP par requête pass def _json(self, code: int, data: dict) -> None: body = json.dumps(data, ensure_ascii=False).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _authorized(self) -> bool: if not TOKEN: # pas de jeton configuré → service verrouillé (refuse tout) return False auth = self.headers.get("Authorization", "") prefix = "Bearer " if not auth.startswith(prefix): return False return hmac.compare_digest(auth[len(prefix):], TOKEN) def do_GET(self): if self.path == "/health": self._json(200, {"ok": True}) else: self._json(404, {"error": "not_found"}) def do_POST(self): if self.path != "/exec": self._json(404, {"error": "not_found"}) return if not self._authorized(): self._json(401, {"error": "unauthorized"}) return try: length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) if length > 0 else {} except (ValueError, TypeError): self._json(400, {"error": "invalid_json"}) return prompt = (body.get("prompt") or "").strip() if not prompt: self._json(400, {"error": "missing 'prompt'"}) return self._json(200, run_hermes(prompt)) if __name__ == "__main__": if not TOKEN: print("hermes-exec : ⚠️ HERMES_EXEC_TOKEN vide → toutes les requêtes seront refusées", flush=True) print(f"hermes-exec — écoute sur 0.0.0.0:{PORT} (hermes={HERMES_BIN}, timeout={TIMEOUT}s)", flush=True) ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()