"""Pairing, webhooks, gateway lifecycle, credential pool, memory provider and operations (doctor/backup/import/hooks/checkpoints) dashboard routes. Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on ``web_server`` stay there and are imported lazily at call time (cycle-safe). """ import logging import re import tempfile import zipfile import asyncio import os import secrets from datetime import datetime, timezone from fastapi import APIRouter from hermes_cli.web_routers._common import http_failure from hermes_cli.web_deps import late from fastapi import File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse from hermes_cli.config import redact_key from hermes_cli.web_models import PairingApprove, PairingRevoke, WebhookCreate, WebhookEnabledToggle, CredentialPoolAdd, MemoryProviderSelect, MemoryReset, BackupRequest, ImportRequest, HookCreate, HookDelete from pathlib import Path from typing import Any, Dict, List, Optional _log = logging.getLogger("hermes_cli.web_server") router = APIRouter() # web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative. _discover_memory_provider_statuses = late("_discover_memory_provider_statuses") _gateway_subcommand = late("_gateway_subcommand") _normalize_memory_provider_name = late("_normalize_memory_provider_name") _path_is_under = late("_path_is_under") _require_memory_provider_ready = late("_require_memory_provider_ready") _resolve_profile_dir = late("_resolve_profile_dir") _spawn_hermes_action = late("_spawn_hermes_action") _write_platform_enabled = late("_write_platform_enabled") get_hermes_home = late("get_hermes_home") load_config = late("load_config") save_config = late("save_config") _restart_gateway_after = late("_restart_gateway_after") def _restart_gateway_after_webhook_enable(profile: Optional[str] = None) -> dict[str, Any]: return _restart_gateway_after(profile, what="enabling webhooks", label="Webhook enable") # --------------------------------------------------------------------------- # Pairing endpoints — approve / revoke / list messaging pairing codes. # # These are how a remote admin onboards messaging users (Telegram, Discord, …) # without shell access. Wraps gateway.pairing.PairingStore directly. # --------------------------------------------------------------------------- def _pairing_store(profile: Optional[str] = None): """Pairing store for ``profile`` — the dashboard's own when unspecified. Every other admin endpoint scopes by profile, and the gateway already keeps one store per served profile (``gateway/run.py``). Without this the dashboard and desktop always read the global store, so an operator on a named profile approves into a whitelist their gateway never consults. ``PairingStore`` resolves the profile's home itself (``default`` maps back to the global store), so this only needs to validate the name — no ``_profile_scope`` needed, and nothing process-global is swapped across the ``await`` boundary. """ from gateway.pairing import PairingStore requested = (profile or "").strip() if not requested or requested.lower() == "current": return PairingStore() _resolve_profile_dir(requested) # 400/404 on an unknown profile return PairingStore(profile=requested) @router.get("/api/pairing") async def list_pairing(profile: Optional[str] = None): store = _pairing_store(profile) return { "pending": store.list_pending(), "approved": store.list_approved(), } @router.post("/api/pairing/approve") async def approve_pairing(body: PairingApprove): store = _pairing_store(body.profile) platform = (body.platform or "").lower().strip() # `request_id` is what an admin surface sends after listing pending # requests; `code` is the one-time code the user relays from their DM. # A GUI that only knows the older field name still works — a value with # request-id shape routes to the request path either way. target = (body.request_id or body.code or "").strip() if not platform or not target: raise HTTPException( status_code=400, detail="platform and request_id or code are required" ) by_request_id = bool(body.request_id) or store.looks_like_request_id(target) if by_request_id: result = store.approve_request(platform, target) else: result = store.approve_code(platform, target.upper()) if result: return {"ok": True, "user": result} # Lockout only gates the code path, so only report it there — otherwise a # stale request id would surface as a bogus 429 while the platform sat # locked out for an unrelated reason. if not by_request_id and store._is_locked_out(platform): raise HTTPException( status_code=429, detail=f"Platform '{platform}' is locked out after too many failed approvals.", ) raise HTTPException( status_code=404, detail=f"Pairing request or code not found or expired for platform '{platform}'.", ) @router.post("/api/pairing/revoke") async def revoke_pairing(body: PairingRevoke): store = _pairing_store(body.profile) platform = (body.platform or "").lower().strip() if not platform or not body.user_id: raise HTTPException(status_code=400, detail="platform and user_id are required") if store.revoke(platform, body.user_id): return {"ok": True} raise HTTPException( status_code=404, detail=f"User {body.user_id} not found in approved list for {platform}.", ) @router.post("/api/pairing/clear-pending") async def clear_pending_pairing(profile: Optional[str] = None): store = _pairing_store(profile) count = store.clear_pending() return {"ok": True, "cleared": count} # --------------------------------------------------------------------------- # Webhook subscription endpoints — list / subscribe / remove. # # Wraps the same JSON store the CLI uses (hermes_cli.webhook); the webhook # adapter hot-reloads it without a gateway restart. Per-route HMAC secrets # are redacted on read and surfaced once on create. # --------------------------------------------------------------------------- def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> Dict[str, Any]: return { "name": name, "description": route.get("description", ""), "events": list(route.get("events") or []), "deliver": route.get("deliver", "log"), "deliver_only": bool(route.get("deliver_only")), "prompt": route.get("prompt", ""), "script": route.get("script", ""), "skills": list(route.get("skills") or []), "created_at": route.get("created_at"), "url": f"{base_url}/webhooks/{name}", # Secret is masked on read; full value only returned on create. "secret_set": bool(route.get("secret")), # Default-enabled; only an explicit enabled:false turns a route off. "enabled": route.get("enabled", True) is not False, } @router.get("/api/webhooks") async def list_webhooks(): import hermes_cli.webhook as wh base_url = wh._get_webhook_base_url() subs = wh._load_subscriptions() return { "enabled": wh._is_webhook_enabled(), "base_url": base_url, "subscriptions": [ _webhook_route_summary(name, route, base_url) for name, route in subs.items() ], } @router.post("/api/webhooks/enable") async def enable_webhooks(): try: _write_platform_enabled("webhook", True) except Exception as exc: _log.exception("Failed to enable webhook platform from dashboard") raise HTTPException( status_code=500, detail="Failed to enable webhook platform.", ) from exc restart_result = _restart_gateway_after_webhook_enable() return { "ok": True, "platform": "webhook", "enabled": True, "needs_restart": not restart_result["restart_started"], **restart_result, } @router.post("/api/webhooks") async def create_webhook(body: WebhookCreate): import re as _re import secrets as _secrets import time as _time import hermes_cli.webhook as wh if not wh._is_webhook_enabled(): raise HTTPException( status_code=400, detail="Webhook platform is not enabled. Enable it from the Webhooks page first.", ) name = (body.name or "").strip().lower().replace(" ", "-") if not _re.match(r"^[a-z0-9][a-z0-9_-]*$", name): raise HTTPException( status_code=400, detail="Invalid name. Use lowercase alphanumeric with hyphens/underscores.", ) if body.deliver_only and body.deliver == "log": raise HTTPException( status_code=400, detail="Direct delivery requires a real target (telegram, discord, …), not 'log'.", ) secret = body.secret or _secrets.token_urlsafe(32) route: Dict[str, Any] = { "description": body.description or f"Dashboard-created subscription: {name}", "events": [e.strip() for e in body.events if e.strip()], "secret": secret, "prompt": body.prompt or "", "skills": [s.strip() for s in body.skills if s.strip()], "deliver": body.deliver or "log", "created_at": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()), } if body.script and body.script.strip(): route["script"] = body.script.strip() if body.deliver_only: route["deliver_only"] = True if body.deliver_chat_id: route["deliver_extra"] = {"chat_id": body.deliver_chat_id} subs = wh._load_subscriptions() subs[name] = route wh._save_subscriptions(subs) base_url = wh._get_webhook_base_url() summary = _webhook_route_summary(name, route, base_url) # Surface the secret exactly once, on create. summary["secret"] = secret return summary @router.delete("/api/webhooks/{name}") async def delete_webhook(name: str): import hermes_cli.webhook as wh key = (name or "").strip().lower() subs = wh._load_subscriptions() if key not in subs: raise HTTPException(status_code=404, detail=f"No subscription named '{key}'") del subs[key] wh._save_subscriptions(subs) return {"ok": True} @router.put("/api/webhooks/{name}/enabled") async def set_webhook_enabled(name: str, body: WebhookEnabledToggle): """Enable or disable a webhook route. Disabled routes stay in the subscriptions file (so they can be re-enabled) but the gateway rejects incoming events with 403. The gateway hot-reloads the subscriptions file, so this takes effect on the next event without a restart. """ import hermes_cli.webhook as wh key = (name or "").strip().lower() subs = wh._load_subscriptions() if key not in subs: raise HTTPException(status_code=404, detail=f"No subscription named '{key}'") subs[key]["enabled"] = bool(body.enabled) wh._save_subscriptions(subs) return {"ok": True, "name": key, "enabled": bool(body.enabled)} # --------------------------------------------------------------------------- # Gateway lifecycle endpoints — start / stop. # # restart + update already exist above; these complete the lifecycle so a # remote admin can bring the gateway up or down without shell access. Both # spawn the real `hermes gateway ` so behaviour matches the CLI exactly. # Status is already surfaced by /api/status (gateway_running/state/platforms). # --------------------------------------------------------------------------- @router.post("/api/gateway/start") async def start_gateway(profile: Optional[str] = None): with http_failure("Failed to spawn gateway start", 500, "Failed to start gateway"): proc = _spawn_hermes_action(_gateway_subcommand(profile, "start"), "gateway-start") return {"ok": True, "pid": proc.pid, "name": "gateway-start"} @router.post("/api/gateway/stop") async def stop_gateway(profile: Optional[str] = None): with http_failure("Failed to spawn gateway stop", 500, "Failed to stop gateway"): proc = _spawn_hermes_action(_gateway_subcommand(profile, "stop"), "gateway-stop") return {"ok": True, "pid": proc.pid, "name": "gateway-stop"} # --------------------------------------------------------------------------- # Credential pool endpoints — list / add / remove rotation keys. # # The credential pool (auth.json -> credential_pool.[]) holds the # rotating API keys the agent round-robins through. Secrets are redacted on # read; only the agent ever sees the raw values at session start. # --------------------------------------------------------------------------- def _pool_entry_summary(entry: Any, index: int) -> Dict[str, Any]: """Redacted, display-safe view of one PooledCredential. ``index`` is 1-based to match CredentialPool.remove_index(). """ token = getattr(entry, "access_token", "") or "" return { "index": index, "id": getattr(entry, "id", None), "label": getattr(entry, "label", None), "auth_type": getattr(entry, "auth_type", None), "source": getattr(entry, "source", None), "priority": getattr(entry, "priority", 0), "last_status": getattr(entry, "last_status", None), "request_count": getattr(entry, "request_count", 0), "token_preview": redact_key(token) if token else "", "has_refresh": bool(getattr(entry, "refresh_token", None)), } @router.get("/api/credentials/pool") async def list_credential_pool(): from agent.credential_pool import load_pool from hermes_cli.auth import read_credential_pool # load_pool() may hit the network synchronously (Copilot token exchange # over raw urllib). urllib's timeout does NOT bound DNS resolution # (getaddrinfo blocks in C), so on a networkless Windows host this froze # the uvicorn event loop for 17 minutes (2026-08-22 00:03-00:20 stall). # Keep every provider load off the loop - same pattern as # get_memory_status below. def _run(): providers = [] # read_credential_pool(None) lists every provider that has pooled entries; # load_pool() then gives us the rich PooledCredential objects per provider. raw_pool = read_credential_pool() for provider_id in sorted(raw_pool.keys()): try: pool = load_pool(provider_id) except Exception: _log.exception("load_pool(%s) failed", provider_id) continue entries = pool.entries() if not entries: continue providers.append({ "provider": provider_id, "entries": [ _pool_entry_summary(e, i) for i, e in enumerate(entries, start=1) ], }) return {"providers": providers} return await asyncio.to_thread(_run) @router.post("/api/credentials/pool") async def add_credential_pool_entry(body: CredentialPoolAdd): import uuid as _uuid from agent.credential_pool import ( load_pool, PooledCredential, AUTH_TYPE_API_KEY, CUSTOM_POOL_PREFIX, SOURCE_MANUAL, ) provider = (body.provider or "").strip().lower() api_key = (body.api_key or "").strip() if not provider or not api_key: raise HTTPException(status_code=400, detail="provider and api_key are required") # load_pool() may run synchronous OAuth token exchanges (network I/O); # keep it off the event loop - see list_credential_pool (2026-08-22 # 17-minute stall fix). def _run(): try: pool = load_pool(provider) label = (body.label or "").strip() or f"key #{len(pool.entries()) + 1}" entry = PooledCredential( provider=provider, id=_uuid.uuid4().hex[:6], label=label, auth_type=AUTH_TYPE_API_KEY, priority=0, source=SOURCE_MANUAL, access_token=api_key, ) pool.add_entry(entry) # Re-adding a credential is an explicit re-engagement signal: lift # every suppression for this provider so a source deleted earlier # (via DELETE below or `hermes auth add`) can seed again. # Mirrors the `hermes auth add` behaviour in auth_commands.py. if not provider.startswith(CUSTOM_POOL_PREFIX): try: from hermes_cli.auth import ( _load_auth_store, unsuppress_credential_source, ) suppressed = _load_auth_store().get("suppressed_sources", {}) for src in list(suppressed.get(provider, []) or []): unsuppress_credential_source(provider, src) except Exception: _log.exception("unsuppress after pool add failed (non-fatal)") return {"ok": True, "provider": provider, "count": len(pool.entries())} except HTTPException: raise except Exception as exc: _log.exception("POST /api/credentials/pool failed") raise HTTPException(status_code=400, detail=str(exc)) from exc return await asyncio.to_thread(_run) @router.delete("/api/credentials/pool/{provider}/{index}") async def remove_credential_pool_entry(provider: str, index: int): """Remove a pool entry. ``index`` is 1-based (matches the list response). Removal must be sticky (#55217): ``load_pool()`` re-seeds entries from their backing source (.env var, OAuth singleton file, custom-provider config) on every call, so deleting only the pool row silently reverts on the next dashboard refresh. We dispatch through the same RemovalStep registry the CLI ``hermes auth remove`` uses: each source cleans up its external state and suppresses ``(provider, source)`` so the seeders skip it. Manual entries have no registered step — nothing external to clean, no suppression needed (they aren't re-seeded). """ from agent.credential_pool import load_pool from agent.credential_sources import find_removal_step from hermes_cli.auth import suppress_credential_source provider = (provider or "").strip().lower() # load_pool() may run synchronous token exchanges and the removal steps do # blocking disk writes - keep them off the event loop (see # list_credential_pool; 2026-08-22 17-minute stall fix). def _run(): try: pool = load_pool(provider) removed = pool.remove_index(index) except Exception as exc: _log.exception("DELETE /api/credentials/pool failed") raise HTTPException(status_code=400, detail=str(exc)) from exc if removed is None: raise HTTPException(status_code=404, detail="No pool entry at that index") cleaned: List[str] = [] hints: List[str] = [] step = find_removal_step(provider, removed.source or "") if step is not None: try: result = step.remove_fn(provider, removed) cleaned = list(result.cleaned) hints = list(result.hints) if result.suppress: suppress_credential_source(provider, removed.source) except Exception: # Cleanup is best-effort, but suppression is the actual bug fix - # without it the entry resurrects on the next load_pool(). Apply # it even when source-specific cleanup blew up. _log.exception( "credential source cleanup failed for %s/%s; suppressing anyway", provider, removed.source, ) try: suppress_credential_source(provider, removed.source) except Exception: _log.exception("suppress_credential_source failed") return { "ok": True, "provider": provider, "count": len(pool.entries()), "cleaned": cleaned, "hints": hints, } return await asyncio.to_thread(_run) # --------------------------------------------------------------------------- # Memory provider endpoints — status / list providers / select / disable / reset. # # Provider setup is dashboard-native when a provider exposes get_config_schema(). # The dashboard never runs interactive provider setup hooks; activation is only # allowed once the provider is discoverable, available, and has required config. # --------------------------------------------------------------------------- @router.get("/api/memory") async def get_memory_status(): # load_config(), file stats and provider discovery are disk reads — keep # them off the event loop. def _run(): cfg = load_config() active = "" mem = cfg.get("memory") if isinstance(mem, dict): active = _normalize_memory_provider_name(mem.get("provider")) # Built-in memory file sizes (so the UI can show what a reset would erase). mem_dir = get_hermes_home() / "memories" files = {} for fname, key in (("MEMORY.md", "memory"), ("USER.md", "user")): path = mem_dir / fname files[key] = path.stat().st_size if path.exists() else 0 return { "active": active, "providers": _discover_memory_provider_statuses(), "builtin_files": files, } return await asyncio.to_thread(_run) @router.put("/api/memory/provider") async def set_memory_provider(body: MemoryProviderSelect): from hermes_cli.web_server import _CONFIG_MUTATION_LOCK provider = _normalize_memory_provider_name(body.provider) def _run(): _require_memory_provider_ready(provider) with _CONFIG_MUTATION_LOCK: cfg = load_config() if not isinstance(cfg.get("memory"), dict): cfg["memory"] = {} cfg["memory"]["provider"] = provider save_config(cfg) return {"ok": True, "active": provider} return await asyncio.to_thread(_run) @router.post("/api/memory/reset") async def reset_memory(body: MemoryReset): target = (body.target or "all").strip().lower() if target not in {"all", "memory", "user"}: raise HTTPException(status_code=400, detail="target must be all, memory, or user") mem_dir = get_hermes_home() / "memories" deleted = [] targets = [] if target in {"all", "memory"}: targets.append("MEMORY.md") if target in {"all", "user"}: targets.append("USER.md") for fname in targets: path = mem_dir / fname if path.exists(): try: path.unlink() deleted.append(fname) except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not delete {fname}: {exc}") return {"ok": True, "deleted": deleted} # --------------------------------------------------------------------------- # Operations endpoints — doctor / security audit / backup / import / # checkpoints / hooks. # # Diagnostic and maintenance commands. The long-running / text-output ones # (doctor, security audit, backup, import, skills install) are spawned as # background actions whose logs the dashboard tails via # /api/actions/{name}/status — same pattern as gateway restart and update. # The cheap, structured reads (hooks list, checkpoints list) return JSON # directly. # --------------------------------------------------------------------------- @router.post("/api/ops/doctor") async def run_doctor(): try: proc = _spawn_hermes_action(["doctor"], "doctor") except Exception as exc: _log.exception("Failed to spawn doctor") raise HTTPException(status_code=500, detail=f"Failed to run doctor: {exc}") return {"ok": True, "pid": proc.pid, "name": "doctor"} @router.post("/api/ops/security-audit") async def run_security_audit(): try: proc = _spawn_hermes_action(["security", "audit"], "security-audit") except Exception as exc: _log.exception("Failed to spawn security audit") raise HTTPException(status_code=500, detail=f"Failed to run security audit: {exc}") return {"ok": True, "pid": proc.pid, "name": "security-audit"} def _dashboard_backup_dir() -> Path: return get_hermes_home() / "backups" def _new_dashboard_backup_path() -> Path: stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") return _dashboard_backup_dir() / f"hermes-backup-{stamp}-{secrets.token_hex(4)}.zip" @router.post("/api/ops/backup") async def run_backup(body: BackupRequest): args = ["backup"] archive: Optional[Path] = None output = (body.output or "").strip() if output: args.extend(["-o", output]) else: archive = _new_dashboard_backup_path() try: archive.parent.mkdir(parents=True, exist_ok=True) except OSError as exc: raise HTTPException( status_code=500, detail=f"Could not create backup directory: {exc}", ) args.extend(["-o", str(archive)]) try: proc = _spawn_hermes_action(args, "backup") except Exception as exc: _log.exception("Failed to spawn backup") raise HTTPException(status_code=500, detail=f"Failed to run backup: {exc}") response = {"ok": True, "pid": proc.pid, "name": "backup"} if archive is not None: response["archive"] = str(archive) return response @router.get("/api/ops/backup/download") async def download_dashboard_backup(archive: str): try: backup_dir = _dashboard_backup_dir().expanduser().resolve(strict=False) target = Path(archive).expanduser().resolve(strict=True) except FileNotFoundError: raise HTTPException(status_code=404, detail="Backup not found") except (OSError, RuntimeError): raise HTTPException(status_code=400, detail="Invalid backup path") if not _path_is_under(backup_dir, target): raise HTTPException(status_code=403, detail="Backup is outside the dashboard backup directory") if not target.is_file(): raise HTTPException(status_code=404, detail="Backup not found") return FileResponse( path=str(target), media_type="application/zip", filename=target.name, content_disposition_type="attachment", ) @router.post("/api/ops/import") async def run_import(body: ImportRequest): archive = (body.archive or "").strip() if not archive: raise HTTPException(status_code=400, detail="archive path is required") if not os.path.isfile(archive): raise HTTPException(status_code=404, detail=f"Archive not found: {archive}") args = ["import", archive] if body.force: args.append("--force") try: proc = _spawn_hermes_action(args, "import") except Exception as exc: _log.exception("Failed to spawn import") raise HTTPException(status_code=500, detail=f"Failed to run import: {exc}") return {"ok": True, "pid": proc.pid, "name": "import"} def _safe_backup_upload_name(filename: str | None) -> str: name = Path(filename or "backup.zip").name.strip() name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-") if not name: name = "backup.zip" if not name.lower().endswith(".zip"): name = f"{name}.zip" return name @router.post("/api/ops/import-upload") async def run_import_upload( file: UploadFile = File(...), force: bool = Form(False), ): from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES, _UPLOAD_CHUNK_BYTES staging_dir = _dashboard_backup_dir() try: staging_dir.mkdir(parents=True, exist_ok=True) except OSError as exc: raise HTTPException( status_code=500, detail=f"Could not create import staging directory: {exc}", ) safe_name = _safe_backup_upload_name(file.filename) stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") target = staging_dir / f"dashboard-import-{stamp}-{secrets.token_hex(4)}-{safe_name}" tmp_fd, tmp_name = tempfile.mkstemp( prefix=f".{target.name}.", suffix=".upload", dir=str(staging_dir), ) tmp_path = Path(tmp_name) total = 0 renamed = False try: with os.fdopen(tmp_fd, "wb") as out: while True: chunk = await file.read(_UPLOAD_CHUNK_BYTES) if not chunk: break total += len(chunk) if total > _MANAGED_FILE_MAX_BYTES: raise HTTPException(status_code=413, detail="Archive is too large") out.write(chunk) os.replace(tmp_path, target) renamed = True except HTTPException: raise except PermissionError: raise HTTPException( status_code=403, detail="Import staging directory is not writable", ) except OSError as exc: raise HTTPException( status_code=500, detail=f"Could not write uploaded archive: {exc}", ) finally: if not renamed: tmp_path.unlink(missing_ok=True) await file.close() if not zipfile.is_zipfile(target): target.unlink(missing_ok=True) raise HTTPException( status_code=400, detail="Uploaded archive is not a valid zip file", ) args = ["import", str(target)] if force: args.append("--force") try: proc = _spawn_hermes_action(args, "import") except Exception as exc: _log.exception("Failed to spawn import") raise HTTPException(status_code=500, detail=f"Failed to run import: {exc}") return { "ok": True, "pid": proc.pid, "name": "import", "archive": str(target), "uploaded_bytes": total, } @router.get("/api/ops/hooks") async def list_hooks(): """List configured shell hooks from config.yaml with consent + health. Reports each hook's allowlist (consent) status and whether the script is currently executable, plus the set of valid hook events so the create form can offer them. """ def _run(): from hermes_cli.config import load_config as _load_config from agent import shell_hooks try: from hermes_cli.plugins import VALID_HOOKS valid_events = sorted(VALID_HOOKS) except Exception: valid_events = [] specs = [] try: specs = shell_hooks.iter_configured_hooks(_load_config()) except Exception: _log.exception("iter_configured_hooks failed") out = [] for spec in specs: entry = None try: entry = shell_hooks.allowlist_entry_for(spec.event, spec.command) except Exception: pass executable = False try: executable = shell_hooks.script_is_executable(spec.command) except Exception: pass out.append({ "event": spec.event, "matcher": spec.matcher, "command": spec.command, "timeout": spec.timeout, "allowed": entry is not None, "approved_at": (entry or {}).get("approved_at"), "executable": executable, }) return {"hooks": out, "valid_events": valid_events} return await asyncio.to_thread(_run) @router.post("/api/ops/hooks") async def create_hook(body: HookCreate): """Add a shell hook to config.yaml (and optionally approve it). Shell hooks run arbitrary commands, so this is a privileged action: it writes to the ``hooks:`` config block and, when ``approve`` is set, records consent in the allowlist so the hook actually fires. Takes effect on the next session / gateway restart. """ from hermes_cli.web_server import _CONFIG_MUTATION_LOCK from agent import shell_hooks event = (body.event or "").strip() command = (body.command or "").strip() if not event or not command: raise HTTPException(status_code=400, detail="event and command are required") try: from hermes_cli.plugins import VALID_HOOKS if event not in VALID_HOOKS: raise HTTPException( status_code=400, detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}", ) except HTTPException: raise except Exception: pass def _run(): with _CONFIG_MUTATION_LOCK: cfg = load_config() hooks_cfg = cfg.get("hooks") if not isinstance(hooks_cfg, dict): hooks_cfg = {} cfg["hooks"] = hooks_cfg entries = hooks_cfg.get(event) if not isinstance(entries, list): entries = [] hooks_cfg[event] = entries new_entry: Dict[str, Any] = {"command": command} if body.matcher: new_entry["matcher"] = body.matcher if body.timeout is not None: new_entry["timeout"] = int(body.timeout) entries.append(new_entry) save_config(cfg) approved = False if body.approve: try: shell_hooks._record_approval(event, command) approved = True except Exception: _log.exception("hook consent record failed") return {"ok": True, "event": event, "command": command, "approved": approved} return await asyncio.to_thread(_run) @router.delete("/api/ops/hooks") async def delete_hook(body: HookDelete): """Remove a hook from config.yaml and revoke its consent allowlist entry.""" from hermes_cli.web_server import _CONFIG_MUTATION_LOCK from agent import shell_hooks event = (body.event or "").strip() command = (body.command or "").strip() if not event or not command: raise HTTPException(status_code=400, detail="event and command are required") def _run(): removed = False with _CONFIG_MUTATION_LOCK: cfg = load_config() hooks_cfg = cfg.get("hooks") if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list): before = len(hooks_cfg[event]) hooks_cfg[event] = [ e for e in hooks_cfg[event] if not (isinstance(e, dict) and e.get("command") == command) ] removed = len(hooks_cfg[event]) < before if not hooks_cfg[event]: del hooks_cfg[event] if not hooks_cfg: cfg.pop("hooks", None) save_config(cfg) # Revoke consent regardless so a re-add re-prompts. try: shell_hooks.revoke(command) except Exception: pass return removed removed = await asyncio.to_thread(_run) if not removed: raise HTTPException(status_code=404, detail="No matching hook found") return {"ok": True} @router.get("/api/ops/checkpoints") async def list_checkpoints(): """List the /rollback shadow store checkpoints (read-only).""" # Checkpoints live under /checkpoints/. Surface a count + # total size so the dashboard can show what a prune would reclaim; the # actual prune is a spawned action so confirmation/pruning logic stays # in one place (the CLI). cp_dir = get_hermes_home() / "checkpoints" sessions = [] total_bytes = 0 if cp_dir.is_dir(): with os.scandir(cp_dir) as scan: children = sorted((Path(e.path) for e in scan), key=lambda p: p.name) for child in children: if not child.is_dir(): continue size = 0 count = 0 for f in child.rglob("*"): if f.is_file(): try: size += f.stat().st_size count += 1 except OSError: pass total_bytes += size sessions.append({ "session": child.name, "files": count, "bytes": size, }) return {"sessions": sessions, "total_bytes": total_bytes} @router.post("/api/ops/checkpoints/prune") async def prune_checkpoints(): try: proc = _spawn_hermes_action(["checkpoints", "prune"], "checkpoints-prune") except Exception as exc: _log.exception("Failed to spawn checkpoints prune") raise HTTPException(status_code=500, detail=f"Failed to prune checkpoints: {exc}") return {"ok": True, "pid": proc.pid, "name": "checkpoints-prune"}