feat(dashboard): full administration panel — MCP, pairing, webhooks, credentials, memory, gateway, ops (#36704)

* feat(dashboard): backend API for MCP, pairing, webhooks, credential pool, memory, gateway lifecycle

Adds REST endpoints so a remote admin can manage these without CLI access:
- MCP servers: list/add/remove/test (config.yaml parity with hermes mcp)
- Pairing: list/approve/revoke/clear-pending messaging codes
- Webhooks: list/subscribe/remove (hot-reloaded JSON store)
- Credential pool: list/add/remove rotation keys (via CredentialPool API)
- Memory provider: status/select/disable/reset
- Gateway lifecycle: start/stop (restart+update already existed)

Secrets redacted on read; usable values only reach the agent at session start.
All endpoints sit behind the existing dashboard auth gate.

* feat(dashboard): backend API for ops + skills hub

- Ops actions (spawned, log-tailed via /api/actions): doctor, security audit,
  backup, import, checkpoints prune
- Ops reads (structured JSON): hooks list + allowlist status, checkpoints list
  with per-session size
- Skills hub actions (spawned): install / uninstall / update
- Registers new action log files for all spawn-based endpoints

All gated by the existing dashboard auth middleware.

* feat(dashboard): admin pages for MCP, pairing, webhooks, and system ops

Adds four new dashboard pages + nav entries so a remote admin can manage
Hermes without CLI access:
- MCP: list/add/remove/test MCP servers
- Webhooks: list/create/delete subscriptions (one-time secret reveal)
- Pairing: approve/revoke/clear messaging pairing codes
- System: gateway start/stop/restart, memory provider + reset, credential
  pool add/remove, ops (doctor/audit/backup/import/skills update) with a
  live action-log viewer, checkpoints prune, shell-hooks status

api.ts: client methods + types for all new endpoints.
App.tsx: routes + sidebar nav (plain labels, no i18n key required).

Verified: tsc -b clean, production build succeeds, new pages lint clean,
zero new eslint errors in App.tsx.

* test(dashboard): cover admin API endpoints

20 tests across MCP, credential pool, memory, pairing, webhooks, ops, plus
an auth-gate parametrize that asserts every admin endpoint requires the
session token. Asserts request contract + CLI-config parity, not catalog
values (per the no-change-detector-tests rule).

* docs(dashboard): document MCP, Webhooks, Pairing, and System admin pages

Adds Pages sections for the four new admin tabs and an Admin-endpoints table
to the REST API reference. Updates the page description to reflect the
dashboard's expanded role as a full administration panel.
This commit is contained in:
Teknium
2026-06-01 02:58:02 -07:00
committed by GitHub
parent 2ed96372ad
commit b571ec298d
9 changed files with 3189 additions and 1 deletions

View File

@ -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 <verb>` 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.<provider>[]) 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 <hermes_home>/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)
# ---------------------------------------------------------------------------

View File

@ -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)

View File

@ -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<string, ComponentType> = {
"/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",

View File

@ -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<McpServer>("/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<McpTestResult>(
`/api/mcp/servers/${encodeURIComponent(name)}/test`,
{ method: "POST" },
),
// ── Admin: Pairing ──────────────────────────────────────────────────
getPairing: () => fetchJSON<PairingResponse>("/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<WebhooksResponse>("/api/webhooks"),
createWebhook: (body: WebhookCreate) =>
fetchJSON<WebhookRoute & { secret: string }>("/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<MemoryStatus>("/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<ActionResponse>("/api/gateway/start", { method: "POST" }),
stopGateway: () =>
fetchJSON<ActionResponse>("/api/gateway/stop", { method: "POST" }),
// ── Admin: Operations ───────────────────────────────────────────────
runDoctor: () =>
fetchJSON<ActionResponse>("/api/ops/doctor", { method: "POST" }),
runSecurityAudit: () =>
fetchJSON<ActionResponse>("/api/ops/security-audit", { method: "POST" }),
runBackup: (output?: string) =>
fetchJSON<ActionResponse>("/api/ops/backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ output }),
}),
runImport: (archive: string) =>
fetchJSON<ActionResponse>("/api/ops/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archive }),
}),
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
getCheckpoints: () => fetchJSON<CheckpointsResponse>("/api/ops/checkpoints"),
pruneCheckpoints: () =>
fetchJSON<ActionResponse>("/api/ops/checkpoints/prune", { method: "POST" }),
// ── Admin: Skills hub ───────────────────────────────────────────────
installSkillFromHub: (identifier: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier }),
}),
uninstallSkillFromHub: (name: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/uninstall", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
updateSkillsFromHub: () =>
fetchJSON<ActionResponse>("/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<string, string>;
auth: string | null;
enabled: boolean;
tools: string[] | null;
}
export interface McpServerCreate {
name: string;
url?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
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

446
web/src/pages/McpPage.tsx Normal file
View File

@ -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<string, string> {
const env: Record<string, string> = {};
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<string, "success" | "warning" | "secondary"> = {
http: "success",
stdio: "warning",
unknown: "secondary",
};
export default function McpPage() {
const [servers, setServers] = useState<McpServer[]>([]);
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<Transport>("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<string | null>(null);
const [testResults, setTestResults] = useState<
Record<string, McpTestResult>
>({});
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(
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
Add Server
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd, loading]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={serverDelete.isOpen}
onCancel={serverDelete.cancel}
onConfirm={serverDelete.confirm}
title="Remove MCP server"
description={
serverDelete.pendingId
? `"${truncateText(serverDelete.pendingId, 40)}" — this will remove the server.`
: "This will remove the server."
}
loading={serverDelete.isDeleting}
/>
{/* Add server modal */}
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) =>
e.target === e.currentTarget && setCreateModalOpen(false)
}
role="dialog"
aria-modal="true"
aria-labelledby="create-mcp-title"
>
<div
className={cn(
themedBody,
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
)}
>
<Button
ghost
size="icon"
onClick={() => setCreateModalOpen(false)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-mcp-title"
className="font-mondwest text-display text-base tracking-wider"
>
Add MCP server
</h2>
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="mcp-name">Name</Label>
<Input
id="mcp-name"
autoFocus
placeholder="my-server"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-transport">Transport</Label>
<Select
id="mcp-transport"
value={transport}
onValueChange={(v) => setTransport(v as Transport)}
>
<SelectOption value="http">HTTP/SSE</SelectOption>
<SelectOption value="stdio">stdio</SelectOption>
</Select>
</div>
{transport === "http" ? (
<div className="grid gap-2">
<Label htmlFor="mcp-url">URL</Label>
<Input
id="mcp-url"
placeholder="https://example.com/mcp"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
) : (
<>
<div className="grid gap-2">
<Label htmlFor="mcp-command">Command</Label>
<Input
id="mcp-command"
placeholder="npx"
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-args">Args</Label>
<Input
id="mcp-args"
placeholder="-y @modelcontextprotocol/server-foo"
value={args}
onChange={(e) => setArgs(e.target.value)}
/>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="mcp-env">Environment (KEY=VALUE per line)</Label>
<textarea
id="mcp-env"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder={"API_KEY=secret\nDEBUG=1"}
value={env}
onChange={(e) => setEnv(e.target.value)}
/>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
prefix={creating ? <Spinner /> : undefined}
>
{creating ? "Adding..." : "Add"}
</Button>
</div>
</div>
</div>
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Server className="h-4 w-4" />
MCP Servers ({servers.length})
</H2>
</div>
{servers.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No MCP servers configured.
</CardContent>
</Card>
)}
{servers.map((server) => {
const envCount = Object.keys(server.env ?? {}).length;
const result = testResults[server.name];
return (
<Card key={server.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{server.name}
</span>
<Badge tone={TRANSPORT_TONE[server.transport] ?? "secondary"}>
{server.transport}
</Badge>
{!server.enabled && (
<Badge tone="outline">disabled</Badge>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{server.transport === "http" ? (
<span className="font-mono truncate">
{server.url ?? "—"}
</span>
) : (
<span className="font-mono truncate">
{[server.command, ...(server.args ?? [])]
.filter(Boolean)
.join(" ") || "—"}
</span>
)}
{envCount > 0 && (
<span>
{envCount} env var{envCount === 1 ? "" : "s"}
</span>
)}
</div>
{result && (
<div className="mt-2 text-xs">
{result.ok ? (
<p className="text-success">
{result.tools.length === 0
? "Connected — no tools"
: `Tools: ${result.tools
.map((tool) => tool.name)
.join(", ")}`}
</p>
) : (
<p className="text-destructive">
{result.error ?? "Connection failed"}
</p>
)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title="Test connection"
aria-label="Test connection"
onClick={() => handleTest(server)}
disabled={testing === server.name}
>
{testing === server.name ? <Spinner /> : <Zap />}
</Button>
<Button
ghost
destructive
size="icon"
title="Delete"
aria-label="Delete"
onClick={() => serverDelete.requestDelete(server.name)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,276 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Check, ShieldCheck, Trash2, Users, X } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
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 { PairingResponse, PairingUser } 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 { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { usePageHeader } from "@/contexts/usePageHeader";
function getUserKey(user: PairingUser): string {
return `${user.platform}:${user.user_id}`;
}
function splitUserKey(key: string): { platform: string; user_id: string } {
const idx = key.indexOf(":");
if (idx === -1) return { platform: "", user_id: key };
return { platform: key.slice(0, idx), user_id: key.slice(idx + 1) };
}
function getUserLabel(user: PairingUser): string {
return user.user_name || user.user_id;
}
export default function PairingPage() {
const [pending, setPending] = useState<PairingUser[]>([]);
const [approved, setApproved] = useState<PairingUser[]>([]);
const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
const loadPairing = useCallback(() => {
api
.getPairing()
.then((res: PairingResponse) => {
setPending(res.pending);
setApproved(res.approved);
})
.catch(() => showToast("Failed to load pairing requests", "error"))
.finally(() => setLoading(false));
}, [showToast]);
useEffect(() => {
loadPairing();
}, [loadPairing]);
const handleApprove = async (user: PairingUser) => {
if (!user.code) {
showToast("Missing pairing code", "error");
return;
}
const key = getUserKey(user);
setApproving(key);
try {
await api.approvePairing(user.platform, user.code);
showToast(`Approved: "${getUserLabel(user)}"`, "success");
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setApproving(null);
}
};
const handleClearPending = async () => {
if (!window.confirm("Clear all pending pairing requests?")) return;
setClearing(true);
try {
const res = await api.clearPendingPairing();
showToast(`Cleared ${res.cleared} pending request(s)`, "success");
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setClearing(false);
}
};
const userRevoke = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const { platform, user_id } = splitUserKey(key);
const user = approved.find((u) => getUserKey(u) === key);
try {
await api.revokePairing(platform, user_id);
showToast(
`Revoked: "${user ? getUserLabel(user) : user_id}"`,
"success",
);
loadPairing();
} catch (e) {
showToast(`Error: ${e}`, "error");
throw e;
}
},
[approved, loadPairing, showToast],
),
});
// Put "Clear pending" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={handleClearPending}
disabled={clearing}
prefix={clearing ? <Spinner /> : <Trash2 className="h-4 w-4" />}
>
Clear pending
</Button>,
);
return () => {
setEnd(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEnd, clearing]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingRevokeUser = userRevoke.pendingId
? approved.find((u) => getUserKey(u) === userRevoke.pendingId)
: null;
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={userRevoke.isOpen}
onCancel={userRevoke.cancel}
onConfirm={userRevoke.confirm}
title="Revoke access"
description={
pendingRevokeUser
? `"${getUserLabel(pendingRevokeUser)}" will lose access. This cannot be undone.`
: "This user will lose access. This cannot be undone."
}
confirmLabel="Revoke"
loading={userRevoke.isDeleting}
/>
{/* Pending requests */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Users className="h-4 w-4" />
Pending requests ({pending.length})
</H2>
{pending.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No pending pairing requests
</CardContent>
</Card>
)}
{pending.map((user) => {
const key = getUserKey(user);
return (
<Card key={key}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge tone="outline">{user.platform}</Badge>
{user.code && (
<span className="font-mono text-sm">{user.code}</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="truncate">{user.user_id}</span>
{user.user_name && (
<span className="truncate">{user.user_name}</span>
)}
{typeof user.age_minutes === "number" && (
<span>{user.age_minutes}m ago</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
className="uppercase"
onClick={() => handleApprove(user)}
disabled={approving === key || !user.code}
prefix={
approving === key ? (
<Spinner />
) : (
<Check className="h-4 w-4" />
)
}
>
Approve
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Approved users */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<ShieldCheck className="h-4 w-4" />
Approved users ({approved.length})
</H2>
{approved.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No approved users
</CardContent>
</Card>
)}
{approved.map((user) => {
const key = getUserKey(user);
return (
<Card key={key}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge tone="outline">{user.platform}</Badge>
<span className="font-medium text-sm truncate">
{user.user_id}
</span>
</div>
{user.user_name && (
<div className="text-xs text-muted-foreground truncate">
{user.user_name}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title="Revoke"
aria-label="Revoke"
className="text-destructive"
onClick={() => userRevoke.requestDelete(key)}
>
<X />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,663 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Activity,
Brain,
Database,
KeyRound,
Play,
Power,
RotateCw,
ShieldCheck,
Stethoscope,
Terminal,
Trash2,
X,
} 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 { 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 { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { api } from "@/lib/api";
import type {
StatusResponse,
MemoryStatus,
CredentialPoolProvider,
CheckpointsResponse,
HooksResponse,
} from "@/lib/api";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* A running-action log viewer. The spawn-based admin actions (doctor,
* security audit, backup, import, skills update, checkpoints prune,
* gateway start/stop) stream their stdout to a per-action log file the
* server tails via /api/actions/<name>/status. When an action is launched
* we poll that endpoint until the process exits, showing live output.
*/
function ActionLogViewer({
action,
onClose,
}: {
action: string;
onClose: () => void;
}) {
const [lines, setLines] = useState<string[]>([]);
const [running, setRunning] = useState(true);
const [exitCode, setExitCode] = useState<number | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const st = await api.getActionStatus(action, 400);
if (cancelled) return;
setLines(st.lines);
setRunning(st.running);
setExitCode(st.exit_code);
if (st.running) {
timer.current = setTimeout(poll, 1200);
}
} catch {
if (!cancelled) {
setRunning(false);
}
}
};
poll();
return () => {
cancelled = true;
if (timer.current) clearTimeout(timer.current);
};
}, [action]);
return (
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-sm">{action}</span>
{running ? (
<Badge tone="warning">running</Badge>
) : (
<Badge tone={exitCode === 0 ? "success" : "destructive"}>
{exitCode === 0 ? "done" : `exit ${exitCode}`}
</Badge>
)}
</div>
<Button ghost size="icon" onClick={onClose} aria-label="Close log">
<X />
</Button>
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words bg-background/50 border border-border p-3 text-xs font-mono text-muted-foreground">
{lines.length ? lines.join("\n") : "Starting…"}
</pre>
</CardContent>
</Card>
);
}
export default function SystemPage() {
const { toast, showToast } = useToast();
const [status, setStatus] = useState<StatusResponse | null>(null);
const [memory, setMemory] = useState<MemoryStatus | null>(null);
const [pool, setPool] = useState<CredentialPoolProvider[]>([]);
const [checkpoints, setCheckpoints] = useState<CheckpointsResponse | null>(
null,
);
const [hooks, setHooks] = useState<HooksResponse | null>(null);
const [loading, setLoading] = useState(true);
// Which spawn-action log is currently shown (null = none).
const [activeAction, setActiveAction] = useState<string | null>(null);
// Add-credential form.
const [credProvider, setCredProvider] = useState("openrouter");
const [credKey, setCredKey] = useState("");
const [credLabel, setCredLabel] = useState("");
const [addingCred, setAddingCred] = useState(false);
// Import archive path.
const [importPath, setImportPath] = useState("");
const loadAll = useCallback(() => {
Promise.allSettled([
api.getStatus(),
api.getMemory(),
api.getCredentialPool(),
api.getCheckpoints(),
api.getHooks(),
])
.then(([s, m, p, c, h]) => {
if (s.status === "fulfilled") setStatus(s.value);
if (m.status === "fulfilled") setMemory(m.value);
if (p.status === "fulfilled") setPool(p.value.providers);
if (c.status === "fulfilled") setCheckpoints(c.value);
if (h.status === "fulfilled") setHooks(h.value);
})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadAll();
}, [loadAll]);
// ── Gateway lifecycle ──────────────────────────────────────────────
const runGateway = async (
verb: "start" | "stop" | "restart",
) => {
try {
if (verb === "start") {
await api.startGateway();
setActiveAction("gateway-start");
} else if (verb === "stop") {
await api.stopGateway();
setActiveAction("gateway-stop");
} else {
await api.restartGateway();
setActiveAction("gateway-restart");
}
showToast(`Gateway ${verb} started`, "success");
setTimeout(loadAll, 3000);
} catch (e) {
showToast(`Gateway ${verb} failed: ${e}`, "error");
}
};
// ── Memory ─────────────────────────────────────────────────────────
const setMemoryProvider = async (provider: string) => {
try {
await api.setMemoryProvider(provider);
showToast(
`Memory provider: ${provider || "built-in only"}`,
"success",
);
loadAll();
} catch (e) {
showToast(`Failed to set provider: ${e}`, "error");
}
};
const memoryReset = useConfirmDelete({
onDelete: useCallback(
async (target: string) => {
try {
const res = await api.resetMemory(
target as "all" | "memory" | "user",
);
showToast(`Reset: ${res.deleted.join(", ") || "nothing"}`, "success");
loadAll();
} catch (e) {
showToast(`Reset failed: ${e}`, "error");
throw e;
}
},
[loadAll, showToast],
),
});
// ── Credential pool ────────────────────────────────────────────────
const addCredential = async () => {
if (!credProvider.trim() || !credKey.trim()) {
showToast("Provider and API key required", "error");
return;
}
setAddingCred(true);
try {
await api.addCredentialPoolEntry(
credProvider.trim(),
credKey.trim(),
credLabel.trim() || undefined,
);
showToast("Credential added", "success");
setCredKey("");
setCredLabel("");
loadAll();
} catch (e) {
showToast(`Failed to add credential: ${e}`, "error");
} finally {
setAddingCred(false);
}
};
const credDelete = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
const [provider, idxStr] = key.split("|");
try {
await api.removeCredentialPoolEntry(provider, Number(idxStr));
showToast("Credential removed", "success");
loadAll();
} catch (e) {
showToast(`Failed to remove: ${e}`, "error");
throw e;
}
},
[loadAll, showToast],
),
});
// ── Operations ─────────────────────────────────────────────────────
const runOp = async (
fn: () => Promise<{ name: string }>,
label: string,
) => {
try {
const res = await fn();
setActiveAction(res.name);
showToast(`${label} started`, "success");
} catch (e) {
showToast(`${label} failed: ${e}`, "error");
}
};
const checkpointsPrune = useConfirmDelete({
onDelete: useCallback(async () => {
try {
const res = await api.pruneCheckpoints();
setActiveAction(res.name);
showToast("Checkpoint prune started", "success");
} catch (e) {
showToast(`Prune failed: ${e}`, "error");
throw e;
}
}, [showToast]),
});
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const gatewayRunning = status?.gateway_running;
return (
<div className="flex flex-col gap-8">
<Toast toast={toast} />
<DeleteConfirmDialog
open={memoryReset.isOpen}
onCancel={memoryReset.cancel}
onConfirm={memoryReset.confirm}
title="Reset memory"
description="This permanently erases the selected built-in memory files. This cannot be undone."
loading={memoryReset.isDeleting}
/>
<DeleteConfirmDialog
open={credDelete.isOpen}
onCancel={credDelete.cancel}
onConfirm={credDelete.confirm}
title="Remove credential"
description="Remove this pooled API key? The agent will no longer rotate through it."
loading={credDelete.isDeleting}
/>
<DeleteConfirmDialog
open={checkpointsPrune.isOpen}
onCancel={checkpointsPrune.cancel}
onConfirm={checkpointsPrune.confirm}
title="Prune checkpoints"
description="Delete the rollback checkpoint shadow store? Existing /rollback points will be lost."
loading={checkpointsPrune.isDeleting}
/>
{/* Live action log */}
{activeAction && (
<ActionLogViewer
action={activeAction}
onClose={() => setActiveAction(null)}
/>
)}
{/* ── Gateway ───────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Power className="h-4 w-4" /> Gateway
</H2>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Badge tone={gatewayRunning ? "success" : "secondary"}>
{gatewayRunning ? "running" : "stopped"}
</Badge>
<span className="text-sm text-muted-foreground">
{status?.gateway_state ?? "—"}
{status?.gateway_pid ? ` · pid ${status.gateway_pid}` : ""}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
className="uppercase"
onClick={() => runGateway("start")}
disabled={gatewayRunning}
prefix={<Play className="h-3.5 w-3.5" />}
>
Start
</Button>
<Button
size="sm"
className="uppercase"
onClick={() => runGateway("restart")}
prefix={<RotateCw className="h-3.5 w-3.5" />}
>
Restart
</Button>
<Button
size="sm"
className="uppercase text-warning"
ghost
onClick={() => runGateway("stop")}
disabled={!gatewayRunning}
prefix={<Power className="h-3.5 w-3.5" />}
>
Stop
</Button>
</div>
</CardContent>
</Card>
</section>
{/* ── Memory ────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Brain className="h-4 w-4" /> Memory
</H2>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="grid gap-2 max-w-sm">
<Label htmlFor="mem-provider">External provider</Label>
<Select
id="mem-provider"
value={memory?.active || ""}
onValueChange={setMemoryProvider}
>
<SelectOption value="">Built-in only</SelectOption>
{(memory?.providers ?? []).map((p) => (
<SelectOption key={p.name} value={p.name}>
{p.name}
{p.configured ? " (configured)" : ""}
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Set up a new provider's credentials with{" "}
<span className="font-mono">hermes memory setup</span>.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<span className="text-xs text-muted-foreground">
Built-in files MEMORY.md:{" "}
{formatBytes(memory?.builtin_files.memory ?? 0)} · USER.md:{" "}
{formatBytes(memory?.builtin_files.user ?? 0)}
</span>
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
ghost
className="text-destructive"
onClick={() => memoryReset.requestDelete("memory")}
>
Reset MEMORY.md
</Button>
<Button
size="sm"
ghost
className="text-destructive"
onClick={() => memoryReset.requestDelete("user")}
>
Reset USER.md
</Button>
<Button
size="sm"
ghost
className="text-destructive"
onClick={() => memoryReset.requestDelete("all")}
>
Reset all
</Button>
</div>
</div>
</CardContent>
</Card>
</section>
{/* ── Credential pool ───────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<KeyRound className="h-4 w-4" /> Credential pool
</H2>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 items-end">
<div className="grid gap-2">
<Label htmlFor="cred-provider">Provider</Label>
<Input
id="cred-provider"
value={credProvider}
onChange={(e) => setCredProvider(e.target.value)}
placeholder="openrouter"
/>
</div>
<div className="grid gap-2 sm:col-span-2">
<Label htmlFor="cred-key">API key</Label>
<Input
id="cred-key"
type="password"
value={credKey}
onChange={(e) => setCredKey(e.target.value)}
placeholder="sk-…"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cred-label">Label</Label>
<Input
id="cred-label"
value={credLabel}
onChange={(e) => setCredLabel(e.target.value)}
placeholder="optional"
/>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
className="uppercase"
onClick={addCredential}
disabled={addingCred}
prefix={addingCred ? <Spinner /> : undefined}
>
Add key
</Button>
</div>
{pool.length === 0 && (
<p className="text-sm text-muted-foreground">
No pooled credentials. Add one above to enable key rotation.
</p>
)}
{pool.map((prov) => (
<div key={prov.provider} className="flex flex-col gap-2">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{prov.provider}
</span>
{prov.entries.map((entry) => (
<div
key={`${prov.provider}-${entry.index}`}
className="flex items-center gap-3 border border-border bg-background/40 px-3 py-2"
>
<span className="text-sm font-medium">{entry.label}</span>
<span className="font-mono text-xs text-muted-foreground">
{entry.token_preview}
</span>
<Badge tone="outline">{entry.auth_type}</Badge>
{entry.last_status && (
<Badge tone="secondary">{entry.last_status}</Badge>
)}
<Button
ghost
size="icon"
className="ml-auto text-destructive"
aria-label="Remove credential"
onClick={() =>
credDelete.requestDelete(
`${prov.provider}|${entry.index}`,
)
}
>
<Trash2 />
</Button>
</div>
))}
</div>
))}
</CardContent>
</Card>
</section>
{/* ── Operations ────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Activity className="h-4 w-4" /> Operations
</H2>
<Card>
<CardContent className="flex flex-wrap gap-2 py-4">
<Button
size="sm"
ghost
prefix={<Stethoscope className="h-3.5 w-3.5" />}
onClick={() => runOp(api.runDoctor, "Doctor")}
>
Run doctor
</Button>
<Button
size="sm"
ghost
prefix={<ShieldCheck className="h-3.5 w-3.5" />}
onClick={() => runOp(api.runSecurityAudit, "Security audit")}
>
Security audit
</Button>
<Button
size="sm"
ghost
prefix={<Database className="h-3.5 w-3.5" />}
onClick={() => runOp(() => api.runBackup(), "Backup")}
>
Backup
</Button>
<Button
size="sm"
ghost
prefix={<RotateCw className="h-3.5 w-3.5" />}
onClick={() => runOp(api.updateSkillsFromHub, "Skills update")}
>
Update skills
</Button>
</CardContent>
</Card>
{/* Import from backup */}
<Card>
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-end">
<div className="grid gap-2 flex-1">
<Label htmlFor="import-path">Restore from backup archive</Label>
<Input
id="import-path"
value={importPath}
onChange={(e) => setImportPath(e.target.value)}
placeholder="/path/to/hermes-backup.zip"
/>
</div>
<Button
size="sm"
ghost
disabled={!importPath.trim()}
onClick={() => {
if (!importPath.trim()) return;
runOp(() => api.runImport(importPath.trim()), "Import");
}}
>
Import
</Button>
</CardContent>
</Card>
</section>
{/* ── Checkpoints ───────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Database className="h-4 w-4" /> Checkpoints
</H2>
<Card>
<CardContent className="flex items-center justify-between py-4">
<span className="text-sm text-muted-foreground">
{checkpoints?.sessions.length ?? 0} session(s) ·{" "}
{formatBytes(checkpoints?.total_bytes ?? 0)}
</span>
<Button
size="sm"
ghost
className="text-destructive"
disabled={!checkpoints?.sessions.length}
prefix={<Trash2 className="h-3.5 w-3.5" />}
onClick={() => checkpointsPrune.requestDelete("all")}
>
Prune
</Button>
</CardContent>
</Card>
</section>
{/* ── Hooks ─────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3">
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
<Terminal className="h-4 w-4" /> Shell hooks
</H2>
{(!hooks || hooks.hooks.length === 0) && (
<Card>
<CardContent className="py-6 text-center text-sm text-muted-foreground">
No shell hooks configured.
</CardContent>
</Card>
)}
{hooks?.hooks.map((h, i) => (
<Card key={`${h.event}-${i}`}>
<CardContent className="flex items-center gap-3 py-3">
<Badge tone="outline">{h.event}</Badge>
{h.matcher && (
<span className="text-xs text-muted-foreground">
matcher: {h.matcher}
</span>
)}
<span className="font-mono text-xs truncate flex-1">
{h.command}
</span>
<Badge tone={h.allowed ? "success" : "warning"}>
{h.allowed ? "allowed" : "not approved"}
</Badge>
</CardContent>
</Card>
))}
</section>
</div>
);
}

View File

@ -0,0 +1,447 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Webhook, Plus, Trash2, X, Copy, Check } 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 { WebhookRoute, WebhooksResponse } 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";
interface CreatedWebhook {
url: string;
secret: string;
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard
.writeText(value)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}, [value]);
return (
<Button
ghost
size="icon"
title="Copy"
aria-label="Copy"
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground"
>
{copied ? <Check /> : <Copy />}
</Button>
);
}
export default function WebhooksPage() {
const [data, setData] = useState<WebhooksResponse | null>(null);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
// New subscription modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [events, setEvents] = useState("");
const [deliver, setDeliver] = useState("log");
const [deliverOnly, setDeliverOnly] = useState(false);
const [prompt, setPrompt] = useState("");
const [creating, setCreating] = useState(false);
const [created, setCreated] = useState<CreatedWebhook | null>(null);
const closeCreateModal = useCallback(() => {
setCreateModalOpen(false);
setCreated(null);
}, []);
const createModalRef = useModalBehavior({
open: createModalOpen,
onClose: closeCreateModal,
});
const enabled = data?.enabled ?? false;
const subscriptions = data?.subscriptions ?? [];
const loadWebhooks = useCallback(() => {
api
.getWebhooks()
.then(setData)
.catch(() => showToast("Failed to load webhooks", "error"))
.finally(() => setLoading(false));
}, [showToast]);
useEffect(() => {
loadWebhooks();
}, [loadWebhooks]);
const resetForm = useCallback(() => {
setName("");
setDescription("");
setEvents("");
setDeliver("log");
setDeliverOnly(false);
setPrompt("");
}, []);
const handleCreate = async () => {
if (!name.trim()) {
showToast("Name required", "error");
return;
}
setCreating(true);
try {
const eventsList = events
.split(",")
.map((e) => e.trim())
.filter(Boolean);
const res = await api.createWebhook({
name: name.trim(),
description: description.trim() || undefined,
events: eventsList.length ? eventsList : undefined,
deliver,
deliver_only: deliverOnly,
prompt: prompt.trim() || undefined,
});
showToast("Created ✓", "success");
setCreated({ url: res.url, secret: res.secret });
resetForm();
loadWebhooks();
} catch (e) {
showToast(`Failed to create: ${e}`, "error");
} finally {
setCreating(false);
}
};
const webhookDelete = useConfirmDelete({
onDelete: useCallback(
async (name: string) => {
try {
await api.deleteWebhook(name);
showToast(`Deleted: "${name}"`, "success");
loadWebhooks();
} catch (e) {
showToast(`Error: ${e}`, "error");
throw e;
}
},
[loadWebhooks, showToast],
),
});
// Put "New subscription" button in page header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
disabled={!enabled}
prefix={<Plus />}
onClick={() => {
setCreated(null);
setCreateModalOpen(true);
}}
>
New subscription
</Button>,
);
return () => {
setEnd(null);
};
}, [setEnd, enabled, loading]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingName = webhookDelete.pendingId ?? "";
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<DeleteConfirmDialog
open={webhookDelete.isOpen}
onCancel={webhookDelete.cancel}
onConfirm={webhookDelete.confirm}
title="Delete webhook"
description={
pendingName
? `"${pendingName}" — this will permanently remove this webhook subscription.`
: "This will permanently remove this webhook subscription."
}
loading={webhookDelete.isDeleting}
/>
{/* Create subscription modal */}
{createModalOpen && (
<div
ref={createModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
role="dialog"
aria-modal="true"
aria-labelledby="create-webhook-title"
>
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh] overflow-y-auto")}>
<Button
ghost
size="icon"
onClick={closeCreateModal}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="create-webhook-title"
className="font-mondwest text-display text-base tracking-wider"
>
New subscription
</h2>
</header>
{created ? (
<div className="p-5 grid gap-4">
<p className="text-sm text-muted-foreground">
Subscription created. Copy the secret now it is only shown
once.
</p>
<div className="grid gap-2">
<Label>Webhook URL</Label>
<div className="flex items-center gap-2 border border-border bg-background/40 px-3 py-2">
<span className="flex-1 min-w-0 truncate font-mono text-xs">
{created.url}
</span>
<CopyButton value={created.url} />
</div>
</div>
<div className="grid gap-2">
<Label>Secret (shown once)</Label>
<div className="flex items-center gap-2 border border-warning/40 bg-warning/10 px-3 py-2">
<span className="flex-1 min-w-0 truncate font-mono text-xs">
{created.secret}
</span>
<CopyButton value={created.secret} />
</div>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={closeCreateModal}
>
Done
</Button>
</div>
</div>
) : (
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="webhook-name">Name</Label>
<Input
id="webhook-name"
autoFocus
placeholder="e.g. github-push"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="webhook-description">Description</Label>
<Input
id="webhook-description"
placeholder="What this webhook does (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="webhook-events">Events</Label>
<Input
id="webhook-events"
placeholder="comma-separated, leave empty for all"
value={events}
onChange={(e) => setEvents(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="webhook-deliver">Deliver to</Label>
<Select
id="webhook-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="log">Log</SelectOption>
<SelectOption value="telegram">Telegram</SelectOption>
<SelectOption value="discord">Discord</SelectOption>
<SelectOption value="slack">Slack</SelectOption>
<SelectOption value="email">Email</SelectOption>
<SelectOption value="github_comment">
GitHub comment
</SelectOption>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="webhook-deliver-only">Deliver only</Label>
<label className="flex items-center gap-2 text-sm text-muted-foreground h-9">
<input
id="webhook-deliver-only"
type="checkbox"
checked={deliverOnly}
onChange={(e) => setDeliverOnly(e.target.checked)}
/>
Skip the agent, deliver payload directly
</label>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="webhook-prompt">Prompt</Label>
<textarea
id="webhook-prompt"
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
placeholder="Instructions for the agent when this webhook fires (optional)"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="flex justify-end">
<Button
className="uppercase"
size="sm"
onClick={handleCreate}
disabled={creating}
prefix={creating ? <Spinner /> : undefined}
>
{creating ? "Creating…" : "Create"}
</Button>
</div>
</div>
)}
</div>
</div>
)}
{!enabled && (
<Card>
<CardContent className="py-6 flex items-start gap-3 text-sm">
<Webhook className="h-5 w-5 shrink-0 text-warning" />
<div className="flex flex-col gap-1">
<span className="font-medium">Webhook platform disabled</span>
<span className="text-muted-foreground">
The webhook platform must be enabled in your messaging settings
before you can create subscriptions. Enable it, then return to
this page.
</span>
</div>
</CardContent>
</Card>
)}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Webhook className="h-4 w-4" />
Subscriptions ({subscriptions.length})
</H2>
{subscriptions.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No webhook subscriptions yet.
</CardContent>
</Card>
)}
{subscriptions.map((sub: WebhookRoute) => (
<Card key={sub.name}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm truncate">
{sub.name}
</span>
<Badge tone="outline">{sub.deliver}</Badge>
{sub.deliver_only && (
<Badge tone="secondary">deliver only</Badge>
)}
</div>
{sub.description && (
<p className="text-xs text-muted-foreground mb-2">
{sub.description}
</p>
)}
<div className="flex items-center gap-1 flex-wrap mb-2">
{sub.events.length === 0 ? (
<Badge tone="secondary">(all)</Badge>
) : (
sub.events.map((evt) => (
<Badge key={evt} tone="secondary">
{evt}
</Badge>
))
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="flex-1 min-w-0 truncate font-mono">
{sub.url}
</span>
<CopyButton value={sub.url} />
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
destructive
size="icon"
title="Delete"
aria-label="Delete"
onClick={() => webhookDelete.requestDelete(sub.name)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
---
sidebar_position: 15
title: "Web Dashboard"
description: "Browser-based dashboard for managing configuration, API keys, sessions, logs, analytics, cron jobs, and skills"
description: "Browser-based administration panel for managing configuration, API keys, MCP servers, messaging pairing, webhooks, the gateway, memory, credentials, sessions, logs, analytics, cron jobs, and skills"
---
# Web Dashboard
@ -185,6 +185,46 @@ Browse, search, and toggle skills and toolsets. Skills are loaded from `~/.herme
- **Toggle** — enable or disable individual skills with a switch. Changes take effect on the next session.
- **Toolsets** — a separate section shows built-in toolsets (file operations, web browsing, etc.) with their active/inactive status, setup requirements, and list of included tools
### MCP
Manage [MCP](/integrations/mcp) servers without the CLI. The same `mcp_servers`
block in `config.yaml` that `hermes mcp` reads from.
- **Add** — register an HTTP/SSE server (URL) or a stdio server (command + args), with optional `KEY=VALUE` environment variables for stdio servers
- **Test** — connect to a server, list its tools, and disconnect — verifies the connection before the agent depends on it
- **Remove** — delete a server from the config
- Secret-shaped env values are redacted in the list view
### Webhooks
Manage dynamic [webhook subscriptions](/user-guide/messaging/webhooks). The
webhook platform must be enabled in messaging settings first; the page shows a
hint when it isn't.
- **Create** — name, description, event filter, delivery target, optional direct-delivery mode, and an agent prompt. On creation the page surfaces the route URL and the one-time HMAC secret to copy.
- **List** — each subscription shows its URL, events, and delivery target
- **Delete** — remove a subscription (hot-reloaded by the gateway, no restart needed)
### Pairing
Approve and revoke messaging users without the CLI — how a remote admin
onboards Telegram/Discord/etc. users to a paired gateway.
- **Pending requests** — each shows platform, code, user, and age, with an Approve button
- **Approved users** — each shows platform and user, with a Revoke button
- **Clear pending** — drop all outstanding pairing codes
### System
A consolidated administration panel for installation-wide operations:
- **Gateway** — start, stop, and restart the messaging gateway, with live status (running/stopped, PID, state)
- **Memory** — pick the external memory provider (or built-in only), and reset the built-in `MEMORY.md` / `USER.md` stores
- **Credential pool** — add and remove the rotating API keys the agent round-robins through (per provider). Keys are redacted in the list; the raw value only ever reaches the agent.
- **Operations** — run `doctor`, a security audit, a backup, restore from a backup archive, or update skills. Each spawns a background action whose live log streams into the page.
- **Checkpoints** — see the `/rollback` shadow store size and prune it
- **Shell hooks** — read-only list of configured hooks with their consent-allowlist status
:::warning Security
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. The dashboard has no authentication of its own.
:::
@ -300,6 +340,36 @@ Enables or disables a skill. Body: `{"name": "skill-name", "enabled": true}`.
Returns all toolsets with their label, description, tools list, and active/configured status.
### Admin endpoints
These power the MCP, Webhooks, Pairing, and System pages. All sit behind the
same auth gate as the rest of `/api/`.
| Method & path | Purpose |
|---------------|---------|
| `GET /api/mcp/servers` | List configured MCP servers (env values redacted) |
| `POST /api/mcp/servers` | Add a server. Body: `{name, url?, command?, args?, env?, auth?}` |
| `POST /api/mcp/servers/{name}/test` | Connect, list tools, disconnect |
| `DELETE /api/mcp/servers/{name}` | Remove a server |
| `GET /api/pairing` | List pending + approved messaging users |
| `POST /api/pairing/approve` | Approve a code. Body: `{platform, code}` |
| `POST /api/pairing/revoke` | Revoke a user. Body: `{platform, user_id}` |
| `POST /api/pairing/clear-pending` | Drop all pending codes |
| `GET /api/webhooks` | List subscriptions + platform-enabled status |
| `POST /api/webhooks` | Create a subscription (returns one-time secret) |
| `DELETE /api/webhooks/{name}` | Remove a subscription |
| `GET /api/credentials/pool` | List pooled rotation keys (redacted) |
| `POST /api/credentials/pool` | Add a key. Body: `{provider, api_key, label?}` |
| `DELETE /api/credentials/pool/{provider}/{index}` | Remove a key (1-based index) |
| `GET /api/memory` | Active provider + available providers + built-in file sizes |
| `PUT /api/memory/provider` | Select a provider (empty = built-in only) |
| `POST /api/memory/reset` | Reset built-in memory. Body: `{target: all\|memory\|user}` |
| `POST /api/gateway/start` · `/stop` · `/restart` | Gateway lifecycle (backgrounded) |
| `POST /api/ops/doctor` · `/security-audit` · `/backup` · `/import` | Diagnostics & maintenance (backgrounded; tail via `/api/actions/{name}/status`) |
| `GET /api/ops/hooks` | Configured shell hooks + allowlist status |
| `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store |
| `POST /api/skills/hub/install` · `/uninstall` · `/update` | Skills hub actions (backgrounded) |
## OAuth Authentication (gated mode)
When the dashboard is bound to a public address — anything other than `127.0.0.1` / `localhost` — Hermes Agent engages an OAuth-based auth gate. Every request must carry a verified session cookie or it's bounced through a full OAuth round-trip via the Nous Portal.