mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 14:24:42 +02:00
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>
This commit is contained in:
parent
5af51f8f66
commit
9a46f6cbcb
16 changed files with 457 additions and 2 deletions
129
tools/hermes-exec/server.py
Normal file
129
tools/hermes-exec/server.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue