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:
ALI YESILKAYA 2026-06-21 04:00:14 +02:00 committed by GitHub
parent 5af51f8f66
commit 9a46f6cbcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 457 additions and 2 deletions

View file

@ -87,7 +87,7 @@ Les tags correspondent aux noms de rôles. Tous les rôles sont idempotents.
### Rôles — storage-01
`hermes_user`, `common`, `gateway` (nftables + NAT), `dnsmasq`, `nfs_server`, `qdrant`, `postgresql`, `minio` (⚠️ rôle vide, service non déployé), `litellm`, `hermes_agent`, `rag`, `node_exporter`, `alertmanager_webhook`, `postfix_relay`, `hermes_auto_improve` (worker doc Hermes + trigger server :9095)
`hermes_user`, `common`, `gateway` (nftables + NAT), `dnsmasq`, `nfs_server`, `qdrant`, `postgresql`, `minio` (⚠️ rôle vide, service non déployé), `litellm`, `hermes_agent`, `rag`, `node_exporter`, `alertmanager_webhook`, `postfix_relay`, `hermes_auto_improve` (worker doc Hermes + trigger server :9095), `hermes_exec` (exécuteur d'actions vocales `hermes -z --yolo` derrière jeton, :9096)
### Rôles — gpu-01
@ -116,6 +116,7 @@ Variables chiffrées dans `ansible/group_vars/all/vault.yml` :
- `vault_pg_hermes_password`, `vault_pg_litellm_password`, `vault_pg_n8n_password`, `vault_pg_openwebui_password`, `vault_pg_ghostfolio_password`
- `vault_postfix_relay_password` — App Password Gmail (relay SMTP)
- `vault_n8n_api_key` — clé API n8n (label "claude") pour gérer les workflows via `/api/v1`
- `vault_hermes_exec_token` — jeton d'auth de `hermes-exec` (actions vocales via Hermes ; même valeur dans le secret k8s `stt-server-secrets/hermes-exec-token`)
---

View file

@ -102,6 +102,15 @@ extraits RAG + mémoire) → alimente le **visualiseur** du HUD (« voir ce qu'o
URLs in-cluster dans `k8s/apps/stt/deployment.yaml` (Prometheus/Alertmanager `monitoring`,
Ghostfolio `ai`). Jeton Ghostfolio = secret optionnel `stt-server-secrets/ghostfolio-token`.
**Contexte « agent » — actions via Hermes (OPT-IN)** : sélectionner le contexte `agent` fait
**agir** Asa sur le homelab. Flux dédié (`agent.py`, court-circuite le LLM) : la demande est
**confirmée** (handshake 2 temps « tu veux que… ? » → « confirme »/« annule »), puis exécutée par
le **vrai agent Hermes** via `hermes-exec` (storage-01:9096 → `hermes -z "<prompt>" --yolo`, profil
défaut, tous outils). Désactivé par défaut (`STT_ACTIONS_ENABLED=false`) ; le contexte n'apparaît
dans `/v1/contexts` que si activé **et** jeton présent. Jeton = `stt-server-secrets/hermes-exec-token`
côté serveur = `vault_hermes_exec_token` côté Ansible (rôle `hermes_exec`). La confirmation protège
des **erreurs de transcription** ; le jeton empêche tout déclenchement non autorisé depuis le cluster.
**Caveat Qwen3 — mode « thinking » (corrigé)** : Qwen3 est un modèle de raisonnement. Sans
précaution, il dépense tout le budget `max_tokens` (200) dans `reasoning_content``content`
**vide**, ou réfléchit trop longtemps → **timeout 502** (`upstream LiteLLM : ` au message vide —

View file

@ -17,3 +17,4 @@
- { role: alertmanager_webhook, tags: [alertmanager_webhook] }
- { role: postfix_relay, tags: [postfix_relay] }
- { role: hermes_auto_improve, tags: [hermes_auto_improve] }
- { role: hermes_exec, tags: [hermes_exec] }

View file

@ -0,0 +1,54 @@
# Rôle `hermes_exec`
Déploie **hermes-exec** sur storage-01 : un petit service HTTP qui lance le vrai agent
Hermes en one-shot (`hermes -z "<prompt>" --yolo`, profil par défaut, tous ses outils)
pour le compte de l'assistant vocal STT.
## Rôle dans la chaîne
```
Voix → STT-server (in-cluster) → [confirmation vocale] → POST /exec {prompt} + Bearer
→ hermes-exec (storage-01:9096)
└─ hermes -z "<prompt>" --yolo
```
## Sécurité
- **Jeton d'auth obligatoire** (`vault_hermes_exec_token`) : sans Bearer valide → `401`.
Sans jeton configuré, le service refuse **tout** (verrouillé par défaut).
- `--yolo` (auto-exécution des outils) n'est atteint qu'avec un jeton valide. La
**confirmation** qui protège des erreurs de transcription est faite **en amont** par le
STT-server (handshake « tu veux que… ? » → « confirme »).
- Jeton stocké dans `/etc/hermes-exec/env` (mode `0640`, groupe `hermes`), pas dans l'unit.
- **Audit** : `/var/log/hermes-exec.log` (prompt + sortie de chaque action).
- Une action à la fois (verrou).
## Variables
Voir `defaults/main.yml`. Le jeton vient du Vault :
```bash
make vault-edit # ajouter : vault_hermes_exec_token: "<chaîne aléatoire longue>"
```
## Déploiement
```bash
ansible-playbook -i inventory.yml playbooks/storage-01.yml --tags hermes_exec
```
## Endpoints
| Méthode | Chemin | Auth | Effet |
|---|---|---|---|
| GET | `/health` | non | `{"ok": true}` |
| POST | `/exec` `{prompt}` | Bearer | lance `hermes -z "<prompt>" --yolo``{"ok", "output"}` |
## Gaps IaC
- Le service suppose l'environnement de `hermes-agent` (HOME `/opt/hermes`, HERMES_HOME
`/srv/data/hermes`, LiteLLM `:4000`). Si l'install Hermes change de chemins, ajuster les
defaults.
- Joignable depuis le cluster sur `:9096` (le firewall gateway laisse passer cluster→s01
sur les ports hauts ; le jeton est la barrière). Si tu durcis nftables, ouvre `:9096`
pour le réseau cluster.

View file

@ -0,0 +1,18 @@
---
# hermes_exec — exécuteur d'actions Hermes pour l'assistant vocal STT.
# Lance `hermes -z "<prompt>" --yolo` (profil défaut, tous outils) derrière un jeton.
hermes_exec_port: 9096
# Binaire hermes (venv de l'agent) + environnement (mêmes valeurs que hermes-agent).
hermes_exec_bin: /opt/hermes/.hermes/hermes-agent/venv/bin/hermes
hermes_exec_user: hermes
hermes_exec_group: hermes
hermes_exec_home: /opt/hermes
hermes_exec_hermes_home: /srv/data/hermes
hermes_exec_lm_base_url: http://127.0.0.1:4000/v1
hermes_exec_lm_api_key: lm-studio
hermes_exec_timeout: 120 # secondes max par action
hermes_exec_log: /var/log/hermes-exec.log
# Jeton d'auth — DOIT venir du Vault (group_vars/all/vault.yml : vault_hermes_exec_token).
hermes_exec_token: "{{ vault_hermes_exec_token | default('') }}"

View file

@ -0,0 +1,9 @@
---
- name: reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: restart hermes-exec
ansible.builtin.systemd:
name: hermes-exec
state: restarted

View file

@ -0,0 +1,65 @@
---
# hermes_exec — déploie l'exécuteur d'actions Hermes (storage-01).
- name: Garde-fou — jeton hermes_exec obligatoire
ansible.builtin.assert:
that:
- hermes_exec_token | length > 0
fail_msg: >-
vault_hermes_exec_token est vide. Ajoute-le dans group_vars/all/vault.yml
(make vault-edit) avant de déployer hermes_exec.
- name: Installer le serveur hermes-exec
ansible.builtin.copy:
src: "{{ inventory_dir }}/../tools/hermes-exec/server.py"
dest: /usr/local/bin/hermes-exec-server
owner: root
group: root
mode: "0755"
notify: restart hermes-exec
- name: Créer le fichier de log (accessible au user hermes)
ansible.builtin.file:
path: "{{ hermes_exec_log }}"
state: touch
owner: "{{ hermes_exec_user }}"
group: "{{ hermes_exec_group }}"
mode: "0640"
modification_time: preserve
access_time: preserve
- name: Répertoire du fichier d'environnement (jeton)
ansible.builtin.file:
path: /etc/hermes-exec
state: directory
owner: root
group: "{{ hermes_exec_group }}"
mode: "0750"
- name: Déposer le jeton (EnvironmentFile à accès restreint)
ansible.builtin.template:
src: hermes-exec.env.j2
dest: /etc/hermes-exec/env
owner: root
group: "{{ hermes_exec_group }}"
mode: "0640"
no_log: true
notify: restart hermes-exec
- name: Unité systemd hermes-exec
ansible.builtin.template:
src: hermes-exec.service.j2
dest: /etc/systemd/system/hermes-exec.service
owner: root
group: root
mode: "0644"
notify:
- reload systemd
- restart hermes-exec
- name: Activer et démarrer hermes-exec
ansible.builtin.systemd:
name: hermes-exec
enabled: true
state: started
daemon_reload: true

View file

@ -0,0 +1,3 @@
# Géré par Ansible (rôle hermes_exec) — NE PAS éditer à la main.
# Jeton d'auth de l'exécuteur Hermes (Bearer attendu par hermes-exec).
HERMES_EXEC_TOKEN={{ hermes_exec_token }}

View file

@ -0,0 +1,29 @@
[Unit]
Description=hermes-exec — exécuteur d'actions Hermes (assistant vocal STT)
After=network-online.target hermes-agent.service litellm.service
Wants=network-online.target
[Service]
User={{ hermes_exec_user }}
Group={{ hermes_exec_group }}
# Jeton chargé depuis un fichier à accès restreint (pas en clair dans l'unit).
EnvironmentFile=/etc/hermes-exec/env
Environment=PYTHONUNBUFFERED=1
Environment=HOME={{ hermes_exec_home }}
Environment=HERMES_HOME={{ hermes_exec_hermes_home }}
Environment=LM_BASE_URL={{ hermes_exec_lm_base_url }}
Environment=LM_API_KEY={{ hermes_exec_lm_api_key }}
Environment=HERMES_BIN={{ hermes_exec_bin }}
Environment=HERMES_EXEC_PORT={{ hermes_exec_port }}
Environment=HERMES_EXEC_TIMEOUT={{ hermes_exec_timeout }}
Environment=HERMES_EXEC_LOG={{ hermes_exec_log }}
ExecStart=/usr/bin/python3 /usr/local/bin/hermes-exec-server
Restart=on-failure
RestartSec=15s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hermes-exec
[Install]
WantedBy=multi-user.target

View file

@ -69,6 +69,21 @@ spec:
name: stt-server-secrets
key: ghostfolio-token
optional: true
# Actions via Hermes (contexte « agent ») — OPT-IN. Mettre "true" + le jeton
# pour activer. hermes-exec écoute sur storage-01:9096 (IP directe).
- name: STT_ACTIONS_ENABLED
value: "false"
- name: STT_HERMES_EXEC_URL
value: "http://192.168.10.1:9096"
# Même jeton que vault_hermes_exec_token (Ansible) :
# kubectl -n ai create secret generic stt-server-secrets \
# --from-literal=hermes-exec-token=<TOKEN> (ou --dry-run -o yaml | kubectl apply)
- name: STT_HERMES_EXEC_TOKEN
valueFrom:
secretKeyRef:
name: stt-server-secrets
key: hermes-exec-token
optional: true
readinessProbe:
httpGet:
path: /healthz

View file

@ -1,3 +1,3 @@
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
__version__ = "0.5.0"
__version__ = "0.6.0"

View file

@ -0,0 +1,55 @@
"""Pont vers hermes-exec (storage-01) — exécution d'actions via le vrai agent Hermes.
Le STT-server appelle hermes-exec **après** confirmation vocale de l'utilisateur. Le
jeton d'auth (env, depuis un secret k8s) protège l'endpoint. Tout est best-effort :
une erreur renvoie un message parlable, jamais une exception.
"""
from __future__ import annotations
import unicodedata
import httpx
from stt_server.config import settings
# Mots de (dé)confirmation pour le handshake en 2 temps.
_CONFIRM = {"confirme", "confirmer", "oui", "vas-y", "vas y", "valide", "ok", "go",
"d'accord", "daccord", "c'est bon", "cest bon", "fais-le", "fais le"}
_CANCEL = {"annule", "annuler", "non", "stop", "laisse tomber", "laisse",
"oublie", "abandonne"}
def _norm(s: str) -> str:
s = unicodedata.normalize("NFKD", str(s))
s = "".join(c for c in s if not unicodedata.combining(c))
return " ".join(s.lower().split()).strip(" .!?")
def is_confirmation(text: str) -> bool:
return _norm(text) in _CONFIRM
def is_cancellation(text: str) -> bool:
return _norm(text) in _CANCEL
async def run_action(prompt: str) -> str:
"""Exécute `prompt` via hermes-exec ; renvoie la sortie de l'agent (ou une erreur lisible)."""
if not settings.hermes_exec_token:
return "Les actions ne sont pas configurées côté serveur (jeton manquant)."
url = settings.hermes_exec_url.rstrip("/") + "/exec"
headers = {"Authorization": f"Bearer {settings.hermes_exec_token}"}
try:
async with httpx.AsyncClient(timeout=settings.hermes_exec_timeout) as c:
r = await c.post(url, json={"prompt": prompt}, headers=headers)
if r.status_code == 401:
return "Refusé par l'exécuteur Hermes (jeton invalide)."
r.raise_for_status()
data = r.json()
out = (data.get("output") or "").strip()
return out or "Action effectuée (aucune sortie)."
except httpx.HTTPError:
return "Je n'arrive pas à joindre l'exécuteur Hermes pour le moment."
except ValueError:
return "Réponse inattendue de l'exécuteur Hermes."

View file

@ -19,6 +19,7 @@ from stt_server import __version__
from stt_server import brain
from stt_server.brain import ask as brain_ask
from stt_server.config import settings
from stt_server import agent
from stt_server.contexts import CONTEXTS, assemble, get_context
from stt_server.knowledge import Knowledge
from stt_server.longterm import LongTermMemory
@ -39,6 +40,13 @@ sessions = SessionStore()
longterm = LongTermMemory() if settings.memory_longterm else None
knowledge = Knowledge() if settings.docs_rag else None
# Contexte « agent » : action en attente de confirmation, par session.
pending_actions: dict[str, str] = {}
def _actions_available() -> bool:
return settings.actions_enabled and bool(settings.hermes_exec_token)
@asynccontextmanager
async def lifespan(app: FastAPI):
@ -91,6 +99,8 @@ async def v1_contexts() -> dict:
"contexts": [
{"id": c.id, "label": c.label, "icon": c.icon, "description": c.description}
for c in CONTEXTS.values()
# le contexte « agent » n'apparaît que si les actions sont activées + jeton
if c.id != "agent" or _actions_available()
],
}
@ -122,6 +132,44 @@ async def v1_reset(req: AskRequest) -> dict:
return {"status": "reset"}
def _agent_context_debug(detail: dict) -> dict:
"""Structure de visualisation pour le contexte agent (transparence du handshake)."""
return {
"id": "agent", "label": "Agent (actions)", "icon": "🤖",
"system_prompt": "Flux agent : la demande est confirmée puis exécutée par "
"le vrai agent Hermes (hermes -z --yolo).",
"blocks": [{"source": "agent", "title": "Action", "text": str(detail)}],
"docs": [], "memories": [],
}
async def _handle_agent(req: AskRequest, text: str) -> AskReply:
"""Handshake de confirmation puis exécution via hermes-exec. Court-circuite le LLM."""
sid = req.session_id or "anon"
if not _actions_available():
return AskReply(
reply="Les actions sont désactivées côté serveur.",
model=settings.model, context_id="agent",
context=_agent_context_debug({"disabled": True}),
)
pending = pending_actions.get(sid)
if pending and agent.is_confirmation(text):
pending_actions.pop(sid, None)
out = await agent.run_action(pending)
log.info("agent EXEC sid=%s prompt=%r", sid, pending[:120])
return AskReply(reply=out, model=settings.model, context_id="agent",
context=_agent_context_debug({"executed": pending, "result": out[:300]}))
if pending and agent.is_cancellation(text):
pending_actions.pop(sid, None)
return AskReply(reply="C'est annulé.", model=settings.model, context_id="agent",
context=_agent_context_debug({"cancelled": pending}))
# nouvelle demande (ou remplacement) → on stocke et on demande confirmation
pending_actions[sid] = text
reply = f"Tu veux que j'exécute : « {text} » ? Dis « confirme » ou « annule »."
return AskReply(reply=reply, model=settings.model, context_id="agent",
context=_agent_context_debug({"pending": text}))
@app.post("/v1/ask", response_model=AskReply)
async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
text = req.text.strip()
@ -134,6 +182,9 @@ async def v1_ask(req: AskRequest, background: BackgroundTasks) -> AskReply:
detail=f"modèle '{model}' non autorisé ; dispo : {settings.allowed_models}",
)
ctx = get_context(req.context)
# Contexte « agent » : flux dédié (confirmation 2 temps → hermes-exec), sans LLM.
if ctx.id == "agent":
return await _handle_agent(req, text)
t0 = time.perf_counter()
history = sessions.history(req.session_id) if req.session_id else None
# recall : timeout serré, dégrade vite ; renvoie aussi le vecteur (réutilisé ci-dessous)

View file

@ -78,5 +78,12 @@ class Settings:
prometheus_url: str = os.getenv("STT_PROMETHEUS_URL", "http://prometheus.lab.local")
alertmanager_url: str = os.getenv("STT_ALERTMANAGER_URL", "http://alertmanager.lab.local")
# Actions via Hermes (contexte « agent ») — OPT-IN, désactivé par défaut.
# Le contexte « agent » n'est exposé que si actions_enabled ET un jeton est présent.
actions_enabled: bool = os.getenv("STT_ACTIONS_ENABLED", "false").lower() == "true"
hermes_exec_url: str = os.getenv("STT_HERMES_EXEC_URL", "http://192.168.10.1:9096")
hermes_exec_token: str = os.getenv("STT_HERMES_EXEC_TOKEN", "")
hermes_exec_timeout: float = float(os.getenv("STT_HERMES_EXEC_TIMEOUT", "130"))
settings = Settings()

View file

@ -71,6 +71,15 @@ CONTEXTS: dict[str, Context] = {
"Tu fais de la LECTURE SEULE : tu n'exécutes aucune action.",
sources=("cluster", "docs"),
),
# Contexte « agent » : court-circuite le LLM — la demande est confirmée puis
# exécutée par le vrai agent Hermes (hermes-exec). Géré entièrement dans app.py
# (handshake), pas via assemble(). Exposé seulement si actions_enabled + jeton.
"agent": Context(
id="agent", label="Agent (actions)", icon="🤖",
description="Demander à Hermes d'AGIR sur le homelab (confirmation requise).",
system_prompt="", # non utilisé : flux dédié
sources=(),
),
}
# Titres des blocs injectés (visualiseur + prompt)

129
tools/hermes-exec/server.py Normal file
View 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()