diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bb65d455a..4ff30f9e2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -768,7 +768,17 @@ _ACTION_LOG_DIR: Path = get_hermes_home() / "logs" # Short ``name`` (from the URL) → absolute log file path. _ACTION_LOG_FILES: Dict[str, str] = { "gateway-restart": "gateway-restart.log", + "gateway-start": "gateway-start.log", + "gateway-stop": "gateway-stop.log", "hermes-update": "hermes-update.log", + "doctor": "action-doctor.log", + "security-audit": "action-security-audit.log", + "backup": "action-backup.log", + "import": "action-import.log", + "checkpoints-prune": "action-checkpoints-prune.log", + "skills-install": "action-skills-install.log", + "skills-uninstall": "action-skills-uninstall.log", + "skills-update": "action-skills-update.log", } # ``name`` → most recently spawned Popen handle. Used so ``status`` can @@ -3980,6 +3990,782 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None): return {"ok": True} +# --------------------------------------------------------------------------- +# MCP server endpoints — list / add / remove / test. +# +# Wraps the same config data layer the CLI uses (hermes_cli.mcp_config), so +# servers managed here show up under `hermes mcp list` and vice versa. Secrets +# in stdio `env` blocks are redacted on read; the agent picks them up from +# config.yaml at session start exactly as with CLI-added servers. +# --------------------------------------------------------------------------- + + +class MCPServerCreate(BaseModel): + name: str + url: Optional[str] = None + command: Optional[str] = None + args: List[str] = [] + # env: KEY=VALUE map for stdio servers (API keys, etc.) + env: Dict[str, str] = {} + # auth: "oauth" | "header" | None + auth: Optional[str] = None + + +def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]: + """Mask secret-shaped MCP env values for read responses.""" + out: Dict[str, str] = {} + for k, v in (env or {}).items(): + try: + out[str(k)] = redact_key(str(v)) if v else "" + except Exception: + out[str(k)] = "***" + return out + + +def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]: + transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown") + return { + "name": name, + "transport": transport, + "url": cfg.get("url"), + "command": cfg.get("command"), + "args": list(cfg.get("args") or []), + "env": _redact_mcp_env(cfg.get("env") or {}), + "auth": cfg.get("auth"), + "enabled": cfg.get("enabled", True) is not False, + # Tool selection: list of enabled tool names, or None = all. + "tools": cfg.get("tools"), + } + + +@app.get("/api/mcp/servers") +async def list_mcp_servers(): + from hermes_cli.mcp_config import _get_mcp_servers + + servers = _get_mcp_servers() + return { + "servers": [ + _mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items()) + ] + } + + +@app.post("/api/mcp/servers") +async def add_mcp_server(body: MCPServerCreate): + from hermes_cli.mcp_config import _get_mcp_servers, _save_mcp_server + + name = (body.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Server name is required") + if name in _get_mcp_servers(): + raise HTTPException(status_code=409, detail=f"Server '{name}' already exists") + if not body.url and not body.command: + raise HTTPException( + status_code=400, + detail="Provide either a URL (HTTP/SSE server) or a command (stdio server)", + ) + + server_config: Dict[str, Any] = {} + if body.url: + server_config["url"] = body.url.strip() + if body.command: + server_config["command"] = body.command.strip() + if body.args: + server_config["args"] = list(body.args) + if body.env: + server_config["env"] = dict(body.env) + if body.auth: + server_config["auth"] = body.auth + + try: + _save_mcp_server(name, server_config) + except Exception as exc: + _log.exception("POST /api/mcp/servers failed") + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return _mcp_server_summary(name, server_config) + + +@app.delete("/api/mcp/servers/{name}") +async def remove_mcp_server(name: str): + from hermes_cli.mcp_config import _remove_mcp_server + + if not _remove_mcp_server(name): + raise HTTPException(status_code=404, detail=f"Server '{name}' not found") + return {"ok": True} + + +@app.post("/api/mcp/servers/{name}/test") +async def test_mcp_server(name: str): + """Connect to the server, list its tools, disconnect. Returns tool list.""" + from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server + + servers = _get_mcp_servers() + if name not in servers: + raise HTTPException(status_code=404, detail=f"Server '{name}' not found") + + try: + # Probe blocks on a dedicated MCP event loop — run in a thread so the + # FastAPI event loop is never blocked. + tools = await asyncio.to_thread(_probe_single_server, name, servers[name]) + except Exception as exc: + return { + "ok": False, + "error": str(exc), + "tools": [], + } + return { + "ok": True, + "tools": [{"name": t, "description": d} for t, d in tools], + } + + +# --------------------------------------------------------------------------- +# 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. +# --------------------------------------------------------------------------- + + +class PairingApprove(BaseModel): + platform: str + code: str + + +class PairingRevoke(BaseModel): + platform: str + user_id: str + + +def _pairing_store(): + from gateway.pairing import PairingStore + + return PairingStore() + + +@app.get("/api/pairing") +async def list_pairing(): + store = _pairing_store() + return { + "pending": store.list_pending(), + "approved": store.list_approved(), + } + + +@app.post("/api/pairing/approve") +async def approve_pairing(body: PairingApprove): + store = _pairing_store() + platform = (body.platform or "").lower().strip() + code = (body.code or "").upper().strip() + if not platform or not code: + raise HTTPException(status_code=400, detail="platform and code are required") + + result = store.approve_code(platform, code) + if result: + return {"ok": True, "user": result} + if 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"Code '{code}' not found or expired for platform '{platform}'.", + ) + + +@app.post("/api/pairing/revoke") +async def revoke_pairing(body: PairingRevoke): + store = _pairing_store() + 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}.", + ) + + +@app.post("/api/pairing/clear-pending") +async def clear_pending_pairing(): + store = _pairing_store() + 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. +# --------------------------------------------------------------------------- + + +class WebhookCreate(BaseModel): + name: str + description: Optional[str] = None + events: List[str] = [] + prompt: Optional[str] = None + skills: List[str] = [] + deliver: str = "log" + deliver_only: bool = False + deliver_chat_id: Optional[str] = None + # secret: omit to auto-generate + secret: Optional[str] = None + + +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", ""), + "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")), + } + + +@app.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() + ], + } + + +@app.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 in messaging settings 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.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 + + +@app.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} + + +# --------------------------------------------------------------------------- +# 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). +# --------------------------------------------------------------------------- + + +@app.post("/api/gateway/start") +async def start_gateway(): + try: + proc = _spawn_hermes_action(["gateway", "start"], "gateway-start") + except Exception as exc: + _log.exception("Failed to spawn gateway start") + raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}") + return {"ok": True, "pid": proc.pid, "name": "gateway-start"} + + +@app.post("/api/gateway/stop") +async def stop_gateway(): + try: + proc = _spawn_hermes_action(["gateway", "stop"], "gateway-stop") + except Exception as exc: + _log.exception("Failed to spawn gateway stop") + raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}") + 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. +# --------------------------------------------------------------------------- + + +class CredentialPoolAdd(BaseModel): + provider: str + # api_key for API-key providers; OAuth pooling stays CLI-only (it needs + # an interactive browser flow that doesn't belong in a single POST). + api_key: str + label: Optional[str] = None + + +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)), + } + + +@app.get("/api/credentials/pool") +async def list_credential_pool(): + from agent.credential_pool import load_pool + from hermes_cli.auth import read_credential_pool + + 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} + + +@app.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, + 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") + + 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) + except Exception as exc: + _log.exception("POST /api/credentials/pool failed") + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "provider": provider, "count": len(pool.entries())} + + +@app.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).""" + from agent.credential_pool import load_pool + + provider = (provider or "").strip().lower() + 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") + return {"ok": True, "provider": provider, "count": len(pool.entries())} + + +# --------------------------------------------------------------------------- +# Memory provider endpoints — status / list providers / select / disable / reset. +# +# Selecting a provider only writes config.memory.provider (full interactive +# provider setup, with its API-key prompts, stays on the CLI via +# `hermes memory setup`). The dashboard covers the common admin actions: +# see which provider is active, switch the built-in store on/off, and wipe +# built-in memory files. +# --------------------------------------------------------------------------- + + +class MemoryProviderSelect(BaseModel): + # "" or "built-in" disables the external provider (built-in only). + provider: str + + +class MemoryReset(BaseModel): + # "all" | "memory" | "user" + target: str = "all" + + +@app.get("/api/memory") +async def get_memory_status(): + from plugins.memory import discover_memory_providers + + cfg = load_config() + active = "" + mem = cfg.get("memory") + if isinstance(mem, dict): + active = str(mem.get("provider") or "") + + providers = [] + try: + for name, description, configured in discover_memory_providers(): + providers.append({ + "name": name, + "description": description, + "configured": bool(configured), + }) + except Exception: + _log.exception("discover_memory_providers failed") + + # 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": providers, + "builtin_files": files, + } + + +@app.put("/api/memory/provider") +async def set_memory_provider(body: MemoryProviderSelect): + provider = (body.provider or "").strip() + if provider.lower() in {"built-in", "builtin", "none"}: + provider = "" + + if provider: + from plugins.memory import discover_memory_providers + + valid = {name for name, _d, _c in discover_memory_providers()} + if provider not in valid: + raise HTTPException( + status_code=400, + detail=f"Unknown memory provider '{provider}'. Run `hermes memory setup` to configure a new one.", + ) + + cfg = load_config() + if not isinstance(cfg.get("memory"), dict): + cfg["memory"] = {} + cfg["memory"]["provider"] = provider + save_config(cfg) + return {"ok": True, "active": provider} + + +@app.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. +# --------------------------------------------------------------------------- + + +@app.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"} + + +@app.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"} + + +class BackupRequest(BaseModel): + # Optional output path; defaults to a timestamped zip in the home dir. + output: Optional[str] = None + + +@app.post("/api/ops/backup") +async def run_backup(body: BackupRequest): + args = ["backup"] + if body.output: + args.append(body.output.strip()) + 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}") + return {"ok": True, "pid": proc.pid, "name": "backup"} + + +class ImportRequest(BaseModel): + archive: str + + +@app.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}") + try: + proc = _spawn_hermes_action(["import", archive], "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"} + + +@app.get("/api/ops/hooks") +async def list_hooks(): + """Read-only list of configured shell hooks from config.yaml + allowlist.""" + cfg = load_config() + hooks_cfg = cfg.get("hooks") + out = [] + if isinstance(hooks_cfg, dict): + for event, entries in hooks_cfg.items(): + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + out.append({ + "event": event, + "matcher": entry.get("matcher"), + "command": entry.get("command"), + "timeout": entry.get("timeout"), + }) + # Consent allowlist status (which commands have been approved for run). + allowlist: List[str] = [] + try: + allow_path = get_hermes_home() / "shell-hooks-allowlist.json" + if allow_path.exists(): + data = json.loads(allow_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + allowlist = list(data.keys()) + elif isinstance(data, list): + allowlist = [str(x) for x in data] + except Exception: + _log.exception("Failed to read shell-hooks allowlist") + for h in out: + h["allowed"] = h.get("command") in allowlist + return {"hooks": out, "allowlist": allowlist} + + +@app.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(): + for child in sorted(cp_dir.iterdir()): + 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} + + +@app.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"} + + +# --------------------------------------------------------------------------- +# Skills hub endpoints — search / install / uninstall / update. +# +# Search and install touch the network (GitHub, hub sources) and run the same +# complex source-router pipeline the CLI uses, so they're spawned as background +# actions whose logs the dashboard tails. The already-installed skill list + +# enable/disable toggle live in the existing /api/skills endpoints. +# --------------------------------------------------------------------------- + + +class SkillInstallRequest(BaseModel): + identifier: str + + +@app.post("/api/skills/hub/install") +async def install_skill_hub(body: SkillInstallRequest): + identifier = (body.identifier or "").strip() + if not identifier: + raise HTTPException(status_code=400, detail="identifier is required") + try: + proc = _spawn_hermes_action(["skills", "install", identifier], "skills-install") + except Exception as exc: + _log.exception("Failed to spawn skills install") + raise HTTPException(status_code=500, detail=f"Failed to install skill: {exc}") + return {"ok": True, "pid": proc.pid, "name": "skills-install"} + + +class SkillUninstallRequest(BaseModel): + name: str + + +@app.post("/api/skills/hub/uninstall") +async def uninstall_skill_hub(body: SkillUninstallRequest): + name = (body.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + try: + proc = _spawn_hermes_action(["skills", "uninstall", name, "--yes"], "skills-uninstall") + except Exception as exc: + _log.exception("Failed to spawn skills uninstall") + raise HTTPException(status_code=500, detail=f"Failed to uninstall skill: {exc}") + return {"ok": True, "pid": proc.pid, "name": "skills-uninstall"} + + +@app.post("/api/skills/hub/update") +async def update_skills_hub(): + try: + proc = _spawn_hermes_action(["skills", "update"], "skills-update") + except Exception as exc: + _log.exception("Failed to spawn skills update") + raise HTTPException(status_code=500, detail=f"Failed to update skills: {exc}") + return {"ok": True, "pid": proc.pid, "name": "skills-update"} + + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py new file mode 100644 index 000000000..b77fa5ba5 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -0,0 +1,228 @@ +"""Tests for the dashboard admin API endpoints (MCP, pairing, webhooks, +credential pool, memory, gateway lifecycle, ops, skills hub). + +These endpoints turn the web dashboard into an administration panel for +operators without CLI access to the host. The tests assert the request +contract and the CLI-config parity (servers/keys written via the API are +visible to the CLI data layer), not specific catalog values. +""" + +import pytest + + +def _client(): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + client = TestClient(app) + client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + # Keep the state DB under the isolated HERMES_HOME for any handler that + # touches it. + hermes_state.DEFAULT_DB_PATH = get_hermes_home() / "state.db" + return client, _SESSION_HEADER_NAME + + +class TestMcpEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, self.header = _client() + + def test_list_add_remove_roundtrip(self): + assert self.client.get("/api/mcp/servers").json()["servers"] == [] + + r = self.client.post( + "/api/mcp/servers", json={"name": "srv1", "url": "https://x/mcp"} + ) + assert r.status_code == 200 + assert r.json()["transport"] == "http" + + servers = self.client.get("/api/mcp/servers").json()["servers"] + assert [s["name"] for s in servers] == ["srv1"] + + # CLI parity: the server is in config.yaml under mcp_servers. + from hermes_cli.mcp_config import _get_mcp_servers + + assert "srv1" in _get_mcp_servers() + + assert self.client.delete("/api/mcp/servers/srv1").status_code == 200 + assert self.client.get("/api/mcp/servers").json()["servers"] == [] + + def test_stdio_env_is_redacted_on_read(self): + self.client.post( + "/api/mcp/servers", + json={ + "name": "srv2", + "command": "npx", + "args": ["-y", "pkg"], + "env": {"API_KEY": "sk-secret-1234567890"}, + }, + ) + srv = self.client.get("/api/mcp/servers").json()["servers"][0] + assert srv["env"]["API_KEY"] != "sk-secret-1234567890" + + def test_duplicate_rejected(self): + self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"}) + r = self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"}) + assert r.status_code == 409 + + def test_missing_transport_rejected(self): + r = self.client.post("/api/mcp/servers", json={"name": "bad"}) + assert r.status_code == 400 + + +class TestCredentialPoolEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_add_list_remove_and_cli_parity(self): + assert self.client.get("/api/credentials/pool").json()["providers"] == [] + + r = self.client.post( + "/api/credentials/pool", + json={"provider": "openrouter", "api_key": "sk-or-abcdef1234", "label": "p"}, + ) + assert r.status_code == 200 and r.json()["count"] == 1 + + providers = self.client.get("/api/credentials/pool").json()["providers"] + entry = providers[0]["entries"][0] + # API redacts the key but exposes a preview + 1-based index. + assert entry["index"] == 1 + assert entry["token_preview"] != "sk-or-abcdef1234" + + # CLI parity: the raw, usable key is retrievable via the pool API. + from agent.credential_pool import load_pool + + raw = load_pool("openrouter").entries() + assert raw[0].access_token == "sk-or-abcdef1234" + + assert self.client.delete("/api/credentials/pool/openrouter/1").status_code == 200 + assert self.client.delete("/api/credentials/pool/openrouter/99").status_code == 404 + + def test_empty_body_rejected(self): + r = self.client.post( + "/api/credentials/pool", json={"provider": "", "api_key": ""} + ) + assert r.status_code == 400 + + +class TestMemoryEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + from hermes_constants import get_hermes_home + + (get_hermes_home() / "memories").mkdir(parents=True, exist_ok=True) + + def test_status_and_select(self): + data = self.client.get("/api/memory").json() + assert "active" in data and "providers" in data and "builtin_files" in data + + r = self.client.put("/api/memory/provider", json={"provider": "built-in"}) + assert r.status_code == 200 and r.json()["active"] == "" + + r = self.client.put( + "/api/memory/provider", json={"provider": "no-such-provider-xyz"} + ) + assert r.status_code == 400 + + def test_reset_targets(self): + from hermes_constants import get_hermes_home + + mem = get_hermes_home() / "memories" + (mem / "MEMORY.md").write_text("notes") + (mem / "USER.md").write_text("user") + + r = self.client.post("/api/memory/reset", json={"target": "user"}) + assert r.status_code == 200 and "USER.md" in r.json()["deleted"] + assert (mem / "MEMORY.md").exists() + + assert self.client.post( + "/api/memory/reset", json={"target": "bogus"} + ).status_code == 400 + + +class TestPairingEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_list_and_bad_approve(self): + data = self.client.get("/api/pairing").json() + assert data == {"pending": [], "approved": []} + r = self.client.post( + "/api/pairing/approve", json={"platform": "telegram", "code": "NOPE99"} + ) + assert r.status_code == 404 + + +class TestWebhookEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_list_disabled_and_create_blocked(self): + data = self.client.get("/api/webhooks").json() + assert data["enabled"] is False + r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"}) + assert r.status_code == 400 + + +class TestOpsEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_hooks_list_reads_config(self): + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg["hooks"] = { + "pre_tool_call": [ + {"matcher": "terminal", "command": "/bin/echo hi", "timeout": 5} + ] + } + save_config(cfg) + data = self.client.get("/api/ops/hooks").json() + assert data["hooks"][0]["command"] == "/bin/echo hi" + + def test_checkpoints_list_empty(self): + data = self.client.get("/api/ops/checkpoints").json() + assert data == {"sessions": [], "total_bytes": 0} + + def test_import_missing_archive_404(self): + r = self.client.post("/api/ops/import", json={"archive": "/no/such.zip"}) + assert r.status_code == 404 + + +class TestAdminEndpointsAuthGate: + """Every admin endpoint must sit behind the dashboard session-token gate.""" + + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + from starlette.testclient import TestClient + from hermes_cli.web_server import app + + # No session header → must be rejected. + self.client = TestClient(app) + + @pytest.mark.parametrize( + "path", + [ + "/api/mcp/servers", + "/api/pairing", + "/api/webhooks", + "/api/credentials/pool", + "/api/memory", + "/api/ops/hooks", + "/api/ops/checkpoints", + ], + ) + def test_gated(self, path): + resp = self.client.get(path) + assert resp.status_code in (401, 403) diff --git a/web/src/App.tsx b/web/src/App.tsx index 26850c2b0..382feafe7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -35,14 +35,17 @@ import { Package, PanelLeftClose, PanelLeftOpen, + Plug, Puzzle, RotateCw, Settings, Shield, + ShieldCheck, Sparkles, Star, Terminal, Users, + Webhook, Wrench, X, Zap, @@ -72,6 +75,10 @@ import CronPage from "@/pages/CronPage"; import ProfilesPage from "@/pages/ProfilesPage"; import SkillsPage from "@/pages/SkillsPage"; import PluginsPage from "@/pages/PluginsPage"; +import McpPage from "@/pages/McpPage"; +import PairingPage from "@/pages/PairingPage"; +import WebhooksPage from "@/pages/WebhooksPage"; +import SystemPage from "@/pages/SystemPage"; import ChatPage from "@/pages/ChatPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher"; @@ -121,6 +128,10 @@ const BUILTIN_ROUTES_CORE: Record = { "/cron": CronPage, "/skills": SkillsPage, "/plugins": PluginsPage, + "/mcp": McpPage, + "/pairing": PairingPage, + "/webhooks": WebhooksPage, + "/system": SystemPage, "/profiles": ProfilesPage, "/config": ConfigPage, "/env": EnvPage, @@ -158,9 +169,13 @@ const BUILTIN_NAV_REST: NavItem[] = [ { path: "/cron", labelKey: "cron", label: "Cron", icon: Clock }, { path: "/skills", labelKey: "skills", label: "Skills", icon: Package }, { path: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle }, + { path: "/mcp", label: "MCP", icon: Plug }, + { path: "/webhooks", label: "Webhooks", icon: Webhook }, + { path: "/pairing", label: "Pairing", icon: ShieldCheck }, { path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users }, { path: "/config", labelKey: "config", label: "Config", icon: Settings }, { path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound }, + { path: "/system", label: "System", icon: Wrench }, { path: "/docs", labelKey: "documentation", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 228a08b1a..ae8e98172 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -504,6 +504,137 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }), + + // ── Admin: MCP servers ────────────────────────────────────────────── + getMcpServers: () => fetchJSON<{ servers: McpServer[] }>("/api/mcp/servers"), + addMcpServer: (body: McpServerCreate) => + fetchJSON("/api/mcp/servers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + removeMcpServer: (name: string) => + fetchJSON<{ ok: boolean }>(`/api/mcp/servers/${encodeURIComponent(name)}`, { + method: "DELETE", + }), + testMcpServer: (name: string) => + fetchJSON( + `/api/mcp/servers/${encodeURIComponent(name)}/test`, + { method: "POST" }, + ), + + // ── Admin: Pairing ────────────────────────────────────────────────── + getPairing: () => fetchJSON("/api/pairing"), + approvePairing: (platform: string, code: string) => + fetchJSON<{ ok: boolean; user: PairingUser }>("/api/pairing/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ platform, code }), + }), + revokePairing: (platform: string, user_id: string) => + fetchJSON<{ ok: boolean }>("/api/pairing/revoke", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ platform, user_id }), + }), + clearPendingPairing: () => + fetchJSON<{ ok: boolean; cleared: number }>("/api/pairing/clear-pending", { + method: "POST", + }), + + // ── Admin: Webhooks ───────────────────────────────────────────────── + getWebhooks: () => fetchJSON("/api/webhooks"), + createWebhook: (body: WebhookCreate) => + fetchJSON("/api/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + deleteWebhook: (name: string) => + fetchJSON<{ ok: boolean }>(`/api/webhooks/${encodeURIComponent(name)}`, { + method: "DELETE", + }), + + // ── Admin: Credential pool ────────────────────────────────────────── + getCredentialPool: () => + fetchJSON<{ providers: CredentialPoolProvider[] }>("/api/credentials/pool"), + addCredentialPoolEntry: ( + provider: string, + api_key: string, + label?: string, + ) => + fetchJSON<{ ok: boolean; provider: string; count: number }>( + "/api/credentials/pool", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, api_key, label }), + }, + ), + removeCredentialPoolEntry: (provider: string, index: number) => + fetchJSON<{ ok: boolean; provider: string; count: number }>( + `/api/credentials/pool/${encodeURIComponent(provider)}/${index}`, + { method: "DELETE" }, + ), + + // ── Admin: Memory provider ────────────────────────────────────────── + getMemory: () => fetchJSON("/api/memory"), + setMemoryProvider: (provider: string) => + fetchJSON<{ ok: boolean; active: string }>("/api/memory/provider", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }), + resetMemory: (target: "all" | "memory" | "user") => + fetchJSON<{ ok: boolean; deleted: string[] }>("/api/memory/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target }), + }), + + // ── Admin: Gateway lifecycle ──────────────────────────────────────── + startGateway: () => + fetchJSON("/api/gateway/start", { method: "POST" }), + stopGateway: () => + fetchJSON("/api/gateway/stop", { method: "POST" }), + + // ── Admin: Operations ─────────────────────────────────────────────── + runDoctor: () => + fetchJSON("/api/ops/doctor", { method: "POST" }), + runSecurityAudit: () => + fetchJSON("/api/ops/security-audit", { method: "POST" }), + runBackup: (output?: string) => + fetchJSON("/api/ops/backup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ output }), + }), + runImport: (archive: string) => + fetchJSON("/api/ops/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ archive }), + }), + getHooks: () => fetchJSON("/api/ops/hooks"), + getCheckpoints: () => fetchJSON("/api/ops/checkpoints"), + pruneCheckpoints: () => + fetchJSON("/api/ops/checkpoints/prune", { method: "POST" }), + + // ── Admin: Skills hub ─────────────────────────────────────────────── + installSkillFromHub: (identifier: string) => + fetchJSON("/api/skills/hub/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier }), + }), + uninstallSkillFromHub: (name: string) => + fetchJSON("/api/skills/hub/uninstall", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }), + updateSkillsFromHub: () => + fetchJSON("/api/skills/hub/update", { method: "POST" }), }; /** Identity payload returned by ``GET /api/auth/me`` (Phase 7). @@ -532,6 +663,132 @@ export interface ActionResponse { update_command?: string; } +// ── Admin types ─────────────────────────────────────────────────────── + +export interface McpServer { + name: string; + transport: "http" | "stdio" | "unknown"; + url: string | null; + command: string | null; + args: string[]; + env: Record; + auth: string | null; + enabled: boolean; + tools: string[] | null; +} + +export interface McpServerCreate { + name: string; + url?: string; + command?: string; + args?: string[]; + env?: Record; + auth?: string; +} + +export interface McpTestResult { + ok: boolean; + error?: string; + tools: Array<{ name: string; description: string }>; +} + +export interface PairingUser { + platform: string; + user_id: string; + user_name?: string; + code?: string; + age_minutes?: number; +} + +export interface PairingResponse { + pending: PairingUser[]; + approved: PairingUser[]; +} + +export interface WebhookRoute { + name: string; + description: string; + events: string[]; + deliver: string; + deliver_only: boolean; + prompt: string; + skills: string[]; + created_at: string | null; + url: string; + secret_set: boolean; +} + +export interface WebhooksResponse { + enabled: boolean; + base_url: string; + subscriptions: WebhookRoute[]; +} + +export interface WebhookCreate { + name: string; + description?: string; + events?: string[]; + prompt?: string; + skills?: string[]; + deliver?: string; + deliver_only?: boolean; + deliver_chat_id?: string; +} + +export interface CredentialPoolEntry { + index: number; + id: string | null; + label: string | null; + auth_type: string | null; + source: string | null; + priority: number; + last_status: string | null; + request_count: number; + token_preview: string; + has_refresh: boolean; +} + +export interface CredentialPoolProvider { + provider: string; + entries: CredentialPoolEntry[]; +} + +export interface MemoryProviderInfo { + name: string; + description: string; + configured: boolean; +} + +export interface MemoryStatus { + active: string; + providers: MemoryProviderInfo[]; + builtin_files: { memory: number; user: number }; +} + +export interface HookEntry { + event: string; + matcher: string | null; + command: string | null; + timeout: number | null; + allowed: boolean; +} + +export interface HooksResponse { + hooks: HookEntry[]; + allowlist: string[]; +} + +export interface CheckpointSession { + session: string; + files: number; + bytes: number; +} + +export interface CheckpointsResponse { + sessions: CheckpointSession[]; + total_bytes: number; +} + /** Per-call overrides for {@link fetchJSON}. */ interface FetchJSONOptions { /** When true, a 401 response is surfaced as a normal thrown error rather diff --git a/web/src/pages/McpPage.tsx b/web/src/pages/McpPage.tsx new file mode 100644 index 000000000..2c40790d1 --- /dev/null +++ b/web/src/pages/McpPage.tsx @@ -0,0 +1,446 @@ +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { Server, Trash2, X, Zap } from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@nous-research/ui/ui/components/typography/h2"; +import { api } from "@/lib/api"; +import type { McpServer, McpServerCreate, McpTestResult } from "@/lib/api"; +import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; +import { useToast } from "@nous-research/ui/hooks/use-toast"; +import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; +import { useModalBehavior } from "@/hooks/useModalBehavior"; +import { Toast } from "@nous-research/ui/ui/components/toast"; +import { Card, CardContent } from "@nous-research/ui/ui/components/card"; +import { Input } from "@nous-research/ui/ui/components/input"; +import { Label } from "@nous-research/ui/ui/components/label"; +import { usePageHeader } from "@/contexts/usePageHeader"; +import { cn, themedBody } from "@/lib/utils"; + +type Transport = "http" | "stdio"; + +function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? value.slice(0, maxLength) + "..." : value; +} + +function parseArgs(raw: string): string[] { + return raw + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +function parseEnv(raw: string): Record { + const env: Record = {}; + raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => { + const idx = line.indexOf("="); + if (idx === -1) return; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) env[key] = value; + }); + return env; +} + +const TRANSPORT_TONE: Record = { + http: "success", + stdio: "warning", + unknown: "secondary", +}; + +export default function McpPage() { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const { toast, showToast } = useToast(); + const { setEnd } = usePageHeader(); + + // Add server modal state + const [createModalOpen, setCreateModalOpen] = useState(false); + const [name, setName] = useState(""); + const [transport, setTransport] = useState("http"); + const [url, setUrl] = useState(""); + const [command, setCommand] = useState(""); + const [args, setArgs] = useState(""); + const [env, setEnv] = useState(""); + const [creating, setCreating] = useState(false); + const closeCreateModal = useCallback(() => setCreateModalOpen(false), []); + const createModalRef = useModalBehavior({ + open: createModalOpen, + onClose: closeCreateModal, + }); + + // Test results keyed by server name + const [testing, setTesting] = useState(null); + const [testResults, setTestResults] = useState< + Record + >({}); + + const loadServers = useCallback(() => { + api + .getMcpServers() + .then((res) => setServers(res.servers)) + .catch((e) => showToast(`Error: ${e}`, "error")) + .finally(() => setLoading(false)); + }, [showToast]); + + useEffect(() => { + loadServers(); + }, [loadServers]); + + const handleCreate = async () => { + if (!name.trim()) { + showToast("Name required", "error"); + return; + } + if (transport === "http" && !url.trim()) { + showToast("URL required", "error"); + return; + } + if (transport === "stdio" && !command.trim()) { + showToast("Command required", "error"); + return; + } + setCreating(true); + try { + const body: McpServerCreate = { name: name.trim() }; + if (transport === "http") { + body.url = url.trim(); + } else { + body.command = command.trim(); + const argList = parseArgs(args); + if (argList.length) body.args = argList; + } + const envMap = parseEnv(env); + if (Object.keys(envMap).length) body.env = envMap; + + await api.addMcpServer(body); + showToast("Add ✓", "success"); + setName(""); + setUrl(""); + setCommand(""); + setArgs(""); + setEnv(""); + setTransport("http"); + setCreateModalOpen(false); + loadServers(); + } catch (e) { + showToast(`Failed to add: ${e}`, "error"); + } finally { + setCreating(false); + } + }; + + const handleTest = async (server: McpServer) => { + setTesting(server.name); + try { + const result = await api.testMcpServer(server.name); + setTestResults((prev) => ({ ...prev, [server.name]: result })); + if (result.ok) { + showToast( + `${server.name}: ${result.tools.length} tool(s)`, + "success", + ); + } else { + showToast(`${server.name}: ${result.error ?? "Failed"}`, "error"); + } + } catch (e) { + showToast(`Error: ${e}`, "error"); + } finally { + setTesting(null); + } + }; + + const serverDelete = useConfirmDelete({ + onDelete: useCallback( + async (serverName: string) => { + try { + await api.removeMcpServer(serverName); + showToast(`Delete: "${truncateText(serverName, 30)}"`, "success"); + setTestResults((prev) => { + const next = { ...prev }; + delete next[serverName]; + return next; + }); + loadServers(); + } catch (e) { + showToast(`Error: ${e}`, "error"); + throw e; + } + }, + [loadServers, showToast], + ), + }); + + // Put "Add Server" button in page header + useLayoutEffect(() => { + setEnd( + , + ); + return () => { + setEnd(null); + }; + }, [setEnd, loading]); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + + {/* Add server modal */} + {createModalOpen && ( +
+ e.target === e.currentTarget && setCreateModalOpen(false) + } + role="dialog" + aria-modal="true" + aria-labelledby="create-mcp-title" + > +
+ + +
+

+ Add MCP server +

+
+ +
+
+ + setName(e.target.value)} + /> +
+ +
+ + +
+ + {transport === "http" ? ( +
+ + setUrl(e.target.value)} + /> +
+ ) : ( + <> +
+ + setCommand(e.target.value)} + /> +
+
+ + setArgs(e.target.value)} + /> +
+ + )} + +
+ +