Funk-lab/tools/hermes-exec/server.py
ALI YESILKAYA 9a46f6cbcb
feat(stt): actions cluster via Hermes — contexte « agent » + exécuteur hermes-exec (#42)
Asa peut AGIR sur le homelab (l'autre moitié de la vision), via le vrai agent Hermes,
profil par défaut, tous ses outils — avec confirmation et jeton.

Découverte : Hermes n'a pas d'API HTTP (appli TUI), mais un mode one-shot `hermes -z
"<prompt>" --yolo`. On s'appuie dessus.

storage-01 — exécuteur :
- tools/hermes-exec/server.py : service HTTP qui lance `hermes -z --yolo` en user hermes,
  derrière un jeton Bearer (compare_digest), timeout + audit, une action à la fois.
- rôle Ansible hermes_exec : systemd (User=hermes, env hermes-agent), jeton via
  EnvironmentFile 0640 (Vault vault_hermes_exec_token) ; ajouté au playbook storage-01.

STT-server (0.5.0 → 0.6.0) :
- agent.py : pont vers hermes-exec (jeton) + détection confirme/annule.
- contexts.py : contexte « agent » (court-circuite le LLM).
- app.py : flux dédié — handshake 2 temps par session (pending action) → sur « confirme »,
  appelle hermes-exec ; « annule » annule. /v1/contexts masque « agent » si désactivé.
- config : STT_ACTIONS_ENABLED (opt-in, défaut false) + URL + jeton (secret k8s).
- deployment : env actions + secret stt-server-secrets/hermes-exec-token (optionnel).

Sécurité : opt-in désactivé par défaut ; jeton obligatoire (sinon contexte caché + exécuteur
refuse tout) ; --yolo atteint seulement jeton+confirmation en main ; audit storage-01.
Client : AUCUN changement (le contexte « agent » apparaît tout seul dans le sélecteur).

Validé en local : exécuteur (401 sans/mauvais jeton, exec avec bon jeton via echo),
handshake serveur (TestClient : confirme→exécute, annule→annule, agent masqué si OFF).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 04:00:14 +02:00

129 lines
5.2 KiB
Python

#!/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 "<prompt>" --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 "<prompt>" --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()