feat(dashboard): complete admin panel — MCP catalog, enable/disable toggles, hook creation, system stats (#36736)
* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats
Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
/api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
uptime/process, stdlib fallback
All gated by dashboard auth; secrets never returned.
* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats
- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats
* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle
Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.
* docs(dashboard): document the comprehensive admin pass + fresh screenshots
Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.
* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops
Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
/api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
(backgrounded, tailed via action status)
Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.
* feat(dashboard): curator + portal + diagnostics UI, tests
- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
Skill curator card (status + pause/resume + run now), and three new
Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
coverage for the new GET endpoints (31 tests total)
* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots
Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.
* feat(dashboard): session stats/export/prune + skills hub search endpoints
Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
POST /api/sessions/prune. /stats is registered before /{session_id} so the
literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)
* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit
Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
install by identifier with a live install log, and 'Update all' (mirrors
hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
updateCronJob, searchSkillsHub + types
Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.
* test(dashboard): cover session stats/rename/export/prune + skills hub search
Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.
* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit
Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
This commit is contained in:
+637
-28
@@ -753,6 +753,225 @@ async def get_status():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/system/stats")
|
||||
async def get_system_stats():
|
||||
"""Host + process system stats for the System page.
|
||||
|
||||
OS / Python / host identity from stdlib; CPU / memory / disk / uptime from
|
||||
psutil when available, with graceful degradation when it isn't. Read-only
|
||||
and non-sensitive (no env values, no paths beyond the hermes home root).
|
||||
"""
|
||||
import platform as _platform
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"os": _platform.system(),
|
||||
"os_release": _platform.release(),
|
||||
"os_version": _platform.version(),
|
||||
"platform": _platform.platform(),
|
||||
"arch": _platform.machine(),
|
||||
"hostname": _platform.node(),
|
||||
"python_version": _platform.python_version(),
|
||||
"python_impl": _platform.python_implementation(),
|
||||
"hermes_version": __version__,
|
||||
"cpu_count": os.cpu_count(),
|
||||
}
|
||||
|
||||
# psutil enriches the picture when present; everything below is optional.
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
info["memory"] = {
|
||||
"total": vm.total,
|
||||
"available": vm.available,
|
||||
"used": vm.used,
|
||||
"percent": vm.percent,
|
||||
}
|
||||
try:
|
||||
du = psutil.disk_usage(str(get_hermes_home()))
|
||||
info["disk"] = {
|
||||
"total": du.total,
|
||||
"used": du.used,
|
||||
"free": du.free,
|
||||
"percent": du.percent,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
|
||||
la = getattr(psutil, "getloadavg", None)
|
||||
if la:
|
||||
info["load_avg"] = list(la())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
boot = psutil.boot_time()
|
||||
info["uptime_seconds"] = int(time.time() - boot)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
info["process"] = {
|
||||
"pid": proc.pid,
|
||||
"rss": proc.memory_info().rss,
|
||||
"create_time": int(proc.create_time()),
|
||||
"num_threads": proc.num_threads(),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
info["psutil"] = True
|
||||
except Exception:
|
||||
info["psutil"] = False
|
||||
# stdlib-only fallbacks for load average + uptime where the kernel
|
||||
# exposes them.
|
||||
try:
|
||||
info["load_avg"] = list(os.getloadavg())
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curator endpoints — background skill-maintenance status + controls.
|
||||
#
|
||||
# The curator periodically reviews skills (archive stale, prune, pin). The
|
||||
# dashboard surfaces its state and the pause/resume/run-now controls that
|
||||
# `hermes curator` exposes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/curator")
|
||||
async def get_curator_status():
|
||||
try:
|
||||
from agent import curator
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}")
|
||||
try:
|
||||
state = curator.load_state()
|
||||
except Exception:
|
||||
state = {}
|
||||
return {
|
||||
"enabled": _safe_call(curator, "is_enabled", True),
|
||||
"paused": _safe_call(curator, "is_paused", False),
|
||||
"interval_hours": _safe_call(curator, "get_interval_hours", None),
|
||||
"last_run_at": state.get("last_run_at"),
|
||||
"min_idle_hours": _safe_call(curator, "get_min_idle_hours", None),
|
||||
"stale_after_days": _safe_call(curator, "get_stale_after_days", None),
|
||||
"archive_after_days": _safe_call(curator, "get_archive_after_days", None),
|
||||
}
|
||||
|
||||
|
||||
class CuratorPause(BaseModel):
|
||||
paused: bool
|
||||
|
||||
|
||||
@app.put("/api/curator/paused")
|
||||
async def set_curator_paused(body: CuratorPause):
|
||||
from agent import curator
|
||||
|
||||
curator.set_paused(bool(body.paused))
|
||||
return {"ok": True, "paused": bool(body.paused)}
|
||||
|
||||
|
||||
@app.post("/api/curator/run")
|
||||
async def run_curator():
|
||||
"""Trigger a curator review now (backgrounded; tail via action status)."""
|
||||
try:
|
||||
proc = _spawn_hermes_action(["curator", "run"], "curator-run")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run curator: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "curator-run"}
|
||||
|
||||
|
||||
def _safe_call(mod, fn_name: str, default):
|
||||
try:
|
||||
fn = getattr(mod, fn_name, None)
|
||||
return fn() if callable(fn) else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portal endpoint — Nous Portal auth + Tool Gateway routing status (read-only).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/portal")
|
||||
async def get_portal_status():
|
||||
cfg = load_config() or {}
|
||||
auth: Dict[str, Any] = {}
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
|
||||
auth = get_nous_auth_status() or {}
|
||||
except Exception:
|
||||
auth = {}
|
||||
|
||||
features = []
|
||||
try:
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
feats = get_nous_subscription_features(cfg)
|
||||
if feats is not None:
|
||||
for feat in feats.items():
|
||||
if getattr(feat, "managed_by_nous", False):
|
||||
state = "via Nous Portal"
|
||||
elif getattr(feat, "active", False) and getattr(feat, "current_provider", None):
|
||||
state = feat.current_provider
|
||||
elif getattr(feat, "active", False):
|
||||
state = "active"
|
||||
else:
|
||||
state = "not configured"
|
||||
features.append({"label": getattr(feat, "label", ""), "state": state})
|
||||
except Exception:
|
||||
_log.exception("portal features failed")
|
||||
|
||||
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
|
||||
return {
|
||||
"logged_in": bool(auth.get("logged_in")),
|
||||
"portal_url": auth.get("portal_base_url"),
|
||||
"inference_url": auth.get("inference_base_url"),
|
||||
"provider": str((model_cfg or {}).get("provider") or ""),
|
||||
"subscription_url": "https://portal.nousresearch.com/manage-subscription",
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostics: prompt-size, support dump, debug upload, config migrate.
|
||||
# All produce text output, so they spawn background actions tailed via
|
||||
# /api/actions/<name>/status.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/api/ops/prompt-size")
|
||||
async def run_prompt_size():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["prompt-size"], "prompt-size")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "prompt-size"}
|
||||
|
||||
|
||||
@app.post("/api/ops/dump")
|
||||
async def run_dump():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["dump"], "dump")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "dump"}
|
||||
|
||||
|
||||
@app.post("/api/ops/config-migrate")
|
||||
async def run_config_migrate():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["config", "migrate"], "config-migrate")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "config-migrate"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway + update actions (invoked from the Status page).
|
||||
#
|
||||
@@ -779,6 +998,10 @@ _ACTION_LOG_FILES: Dict[str, str] = {
|
||||
"skills-install": "action-skills-install.log",
|
||||
"skills-uninstall": "action-skills-uninstall.log",
|
||||
"skills-update": "action-skills-update.log",
|
||||
"curator-run": "action-curator-run.log",
|
||||
"prompt-size": "action-prompt-size.log",
|
||||
"dump": "action-dump.log",
|
||||
"config-migrate": "action-config-migrate.log",
|
||||
}
|
||||
|
||||
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
||||
@@ -3690,6 +3913,38 @@ def _session_latest_descendant(session_id: str):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/stats")
|
||||
async def get_session_stats():
|
||||
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
||||
|
||||
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
|
||||
path isn't captured as a session id by the parameterized route.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
total = db.session_count(include_archived=True)
|
||||
active_store = db.session_count(include_archived=False)
|
||||
archived = db.session_count(archived_only=True)
|
||||
messages = db.message_count()
|
||||
by_source: Dict[str, int] = {}
|
||||
try:
|
||||
for s in db.list_sessions_rich(limit=10000, include_archived=True):
|
||||
src = str(s.get("source") or "cli")
|
||||
by_source[src] = by_source.get(src, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"total": total,
|
||||
"active_store": active_store,
|
||||
"archived": archived,
|
||||
"messages": messages,
|
||||
"by_source": by_source,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
@@ -3782,6 +4037,49 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/export")
|
||||
async def export_session_endpoint(session_id: str):
|
||||
"""Export a single session (metadata + messages) as JSON."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
data = db.export_session(sid)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return data
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class SessionPrune(BaseModel):
|
||||
older_than_days: int = 90
|
||||
source: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/sessions/prune")
|
||||
async def prune_sessions_endpoint(body: SessionPrune):
|
||||
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
|
||||
if body.older_than_days < 1:
|
||||
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
removed = db.prune_sessions(
|
||||
older_than_days=body.older_than_days,
|
||||
source=(body.source or None),
|
||||
sessions_dir=sessions_dir if sessions_dir.exists() else None,
|
||||
)
|
||||
return {"ok": True, "removed": removed}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log viewer endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4172,6 +4470,129 @@ async def test_mcp_server(name: str):
|
||||
}
|
||||
|
||||
|
||||
class MCPEnabledToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@app.put("/api/mcp/servers/{name}/enabled")
|
||||
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
|
||||
"""Enable or disable an MCP server (takes effect on next session/gateway).
|
||||
|
||||
Toggles the ``enabled`` key on the server's config.yaml entry — the same
|
||||
flag the agent reads at startup. Disabled servers stay in config so they
|
||||
can be re-enabled without re-entering their settings.
|
||||
"""
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers")
|
||||
if not isinstance(servers, dict) or name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
if not isinstance(servers[name], dict):
|
||||
raise HTTPException(status_code=400, detail="Malformed server config")
|
||||
servers[name]["enabled"] = bool(body.enabled)
|
||||
save_config(cfg)
|
||||
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
|
||||
|
||||
|
||||
@app.get("/api/mcp/catalog")
|
||||
async def list_mcp_catalog():
|
||||
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
|
||||
|
||||
Each entry reports whether it's already installed and enabled so the UI
|
||||
can show install / enabled state inline. This is the same catalog
|
||||
`hermes mcp catalog` / `hermes mcp install` read.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import mcp_catalog
|
||||
except Exception as exc:
|
||||
_log.exception("mcp_catalog import failed")
|
||||
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in mcp_catalog.list_catalog():
|
||||
auth = entry.auth
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"description": entry.description,
|
||||
"source": entry.source,
|
||||
"transport": entry.transport.type,
|
||||
"auth_type": getattr(auth, "type", "none"),
|
||||
# Env vars the user must supply (names + prompts only, never values).
|
||||
"required_env": [
|
||||
{"name": e.name, "prompt": e.prompt, "required": e.required}
|
||||
for e in getattr(auth, "env", []) or []
|
||||
],
|
||||
"needs_install": entry.install is not None,
|
||||
"installed": mcp_catalog.is_installed(entry.name),
|
||||
"enabled": mcp_catalog.is_enabled(entry.name),
|
||||
})
|
||||
except Exception:
|
||||
_log.exception("list_mcp_catalog failed")
|
||||
|
||||
diagnostics = []
|
||||
try:
|
||||
diagnostics = [
|
||||
{"name": n, "kind": k, "message": m}
|
||||
for (n, k, m) in mcp_catalog.catalog_diagnostics()
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"entries": entries, "diagnostics": diagnostics}
|
||||
|
||||
|
||||
class MCPCatalogInstall(BaseModel):
|
||||
name: str
|
||||
# env: KEY=VALUE map for catalog entries that declare required env vars.
|
||||
env: Dict[str, str] = {}
|
||||
enable: bool = True
|
||||
|
||||
|
||||
@app.post("/api/mcp/catalog/install")
|
||||
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
|
||||
"""Install a catalog MCP into config.yaml.
|
||||
|
||||
For HTTP/stdio entries with required env vars, those are written to .env
|
||||
via the standard env path so the agent can read them at session start.
|
||||
Entries that need a git bootstrap (``needs_install``) are installed via
|
||||
the CLI action path because the clone can take time.
|
||||
"""
|
||||
from hermes_cli import mcp_catalog
|
||||
|
||||
name = (body.name or "").strip()
|
||||
entry = mcp_catalog.get_entry(name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'")
|
||||
|
||||
# Persist any supplied env vars first (catalog entries declare which names
|
||||
# they need; we only write the ones the user provided).
|
||||
if body.env:
|
||||
for k, v in body.env.items():
|
||||
if v:
|
||||
save_env_value(k, v)
|
||||
|
||||
# Git-bootstrap entries can take a while to clone — run via the background
|
||||
# action path so the request returns immediately and the UI can tail logs.
|
||||
if entry.install is not None:
|
||||
try:
|
||||
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
|
||||
return {"ok": True, "name": name, "background": True, "action": "mcp-install"}
|
||||
|
||||
# No git step — install synchronously via the catalog API.
|
||||
try:
|
||||
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
|
||||
except Exception as exc:
|
||||
_log.exception("install_mcp_catalog_entry failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"ok": True, "name": name, "background": False}
|
||||
|
||||
|
||||
# Register the mcp-install action log so /api/actions/mcp-install/status works.
|
||||
_ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pairing endpoints — approve / revoke / list messaging pairing codes.
|
||||
#
|
||||
@@ -4283,6 +4704,8 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D
|
||||
"url": f"{base_url}/webhooks/{name}",
|
||||
# Secret is masked on read; full value only returned on create.
|
||||
"secret_set": bool(route.get("secret")),
|
||||
# Default-enabled; only an explicit enabled:false turns a route off.
|
||||
"enabled": route.get("enabled", True) is not False,
|
||||
}
|
||||
|
||||
|
||||
@@ -4367,6 +4790,30 @@ async def delete_webhook(name: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class WebhookEnabledToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@app.put("/api/webhooks/{name}/enabled")
|
||||
async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
|
||||
"""Enable or disable a webhook route.
|
||||
|
||||
Disabled routes stay in the subscriptions file (so they can be
|
||||
re-enabled) but the gateway rejects incoming events with 403. The
|
||||
gateway hot-reloads the subscriptions file, so this takes effect on the
|
||||
next event without a restart.
|
||||
"""
|
||||
import hermes_cli.webhook as wh
|
||||
|
||||
key = (name or "").strip().lower()
|
||||
subs = wh._load_subscriptions()
|
||||
if key not in subs:
|
||||
raise HTTPException(status_code=404, detail=f"No subscription named '{key}'")
|
||||
subs[key]["enabled"] = bool(body.enabled)
|
||||
wh._save_subscriptions(subs)
|
||||
return {"ok": True, "name": key, "enabled": bool(body.enabled)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway lifecycle endpoints — start / stop.
|
||||
#
|
||||
@@ -4688,38 +5135,160 @@ async def run_import(body: ImportRequest):
|
||||
|
||||
@app.get("/api/ops/hooks")
|
||||
async def list_hooks():
|
||||
"""Read-only list of configured shell hooks from config.yaml + allowlist."""
|
||||
"""List configured shell hooks from config.yaml with consent + health.
|
||||
|
||||
Reports each hook's allowlist (consent) status and whether the script is
|
||||
currently executable, plus the set of valid hook events so the create
|
||||
form can offer them.
|
||||
"""
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
from agent import shell_hooks
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
valid_events = sorted(VALID_HOOKS)
|
||||
except Exception:
|
||||
valid_events = []
|
||||
|
||||
specs = []
|
||||
try:
|
||||
specs = shell_hooks.iter_configured_hooks(_load_config())
|
||||
except Exception:
|
||||
_log.exception("iter_configured_hooks failed")
|
||||
|
||||
out = []
|
||||
for spec in specs:
|
||||
entry = None
|
||||
try:
|
||||
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
|
||||
except Exception:
|
||||
pass
|
||||
executable = False
|
||||
try:
|
||||
executable = shell_hooks.script_is_executable(spec.command)
|
||||
except Exception:
|
||||
pass
|
||||
out.append({
|
||||
"event": spec.event,
|
||||
"matcher": spec.matcher,
|
||||
"command": spec.command,
|
||||
"timeout": spec.timeout,
|
||||
"allowed": entry is not None,
|
||||
"approved_at": (entry or {}).get("approved_at"),
|
||||
"executable": executable,
|
||||
})
|
||||
|
||||
return {"hooks": out, "valid_events": valid_events}
|
||||
|
||||
|
||||
class HookCreate(BaseModel):
|
||||
event: str
|
||||
command: str
|
||||
matcher: Optional[str] = None
|
||||
timeout: Optional[int] = None
|
||||
# approve: write the consent allowlist entry too (the operator using the
|
||||
# authenticated dashboard is giving consent). Without it the hook is
|
||||
# configured but won't fire until approved.
|
||||
approve: bool = True
|
||||
|
||||
|
||||
@app.post("/api/ops/hooks")
|
||||
async def create_hook(body: HookCreate):
|
||||
"""Add a shell hook to config.yaml (and optionally approve it).
|
||||
|
||||
Shell hooks run arbitrary commands, so this is a privileged action: it
|
||||
writes to the ``hooks:`` config block and, when ``approve`` is set, records
|
||||
consent in the allowlist so the hook actually fires. Takes effect on the
|
||||
next session / gateway restart.
|
||||
"""
|
||||
from agent import shell_hooks
|
||||
|
||||
event = (body.event or "").strip()
|
||||
command = (body.command or "").strip()
|
||||
if not event or not command:
|
||||
raise HTTPException(status_code=400, detail="event and command are required")
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
if event not in VALID_HOOKS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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] = []
|
||||
if not isinstance(hooks_cfg, dict):
|
||||
hooks_cfg = {}
|
||||
cfg["hooks"] = hooks_cfg
|
||||
entries = hooks_cfg.get(event)
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
hooks_cfg[event] = entries
|
||||
|
||||
new_entry: Dict[str, Any] = {"command": command}
|
||||
if body.matcher:
|
||||
new_entry["matcher"] = body.matcher
|
||||
if body.timeout is not None:
|
||||
new_entry["timeout"] = int(body.timeout)
|
||||
entries.append(new_entry)
|
||||
save_config(cfg)
|
||||
|
||||
approved = False
|
||||
if body.approve:
|
||||
try:
|
||||
shell_hooks._record_approval(event, command)
|
||||
approved = True
|
||||
except Exception:
|
||||
_log.exception("hook consent record failed")
|
||||
|
||||
return {"ok": True, "event": event, "command": command, "approved": approved}
|
||||
|
||||
|
||||
class HookDelete(BaseModel):
|
||||
event: str
|
||||
command: str
|
||||
|
||||
|
||||
@app.delete("/api/ops/hooks")
|
||||
async def delete_hook(body: HookDelete):
|
||||
"""Remove a hook from config.yaml and revoke its consent allowlist entry."""
|
||||
from agent import shell_hooks
|
||||
|
||||
event = (body.event or "").strip()
|
||||
command = (body.command or "").strip()
|
||||
if not event or not command:
|
||||
raise HTTPException(status_code=400, detail="event and command are required")
|
||||
|
||||
cfg = load_config()
|
||||
hooks_cfg = cfg.get("hooks")
|
||||
removed = False
|
||||
if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list):
|
||||
before = len(hooks_cfg[event])
|
||||
hooks_cfg[event] = [
|
||||
e for e in hooks_cfg[event]
|
||||
if not (isinstance(e, dict) and e.get("command") == command)
|
||||
]
|
||||
removed = len(hooks_cfg[event]) < before
|
||||
if not hooks_cfg[event]:
|
||||
del hooks_cfg[event]
|
||||
if not hooks_cfg:
|
||||
cfg.pop("hooks", None)
|
||||
save_config(cfg)
|
||||
|
||||
# Revoke consent regardless so a re-add re-prompts.
|
||||
try:
|
||||
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]
|
||||
shell_hooks.revoke(command)
|
||||
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}
|
||||
pass
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="No matching hook found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/ops/checkpoints")
|
||||
@@ -4818,6 +5387,46 @@ async def update_skills_hub():
|
||||
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
|
||||
|
||||
|
||||
@app.get("/api/skills/hub/search")
|
||||
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
|
||||
"""Search the skill hub across all configured sources.
|
||||
|
||||
Network-bound (parallel source search); runs in a thread so the FastAPI
|
||||
loop isn't blocked. Returns structured results the UI installs by
|
||||
identifier via POST /api/skills/hub/install.
|
||||
"""
|
||||
query = (q or "").strip()
|
||||
if not query:
|
||||
return {"results": []}
|
||||
|
||||
def _run():
|
||||
from tools.skills_hub import create_source_router, unified_search
|
||||
|
||||
sources = create_source_router()
|
||||
metas = unified_search(
|
||||
query, sources, source_filter=source or "all", limit=min(max(limit, 1), 50)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"name": m.name,
|
||||
"description": m.description,
|
||||
"source": m.source,
|
||||
"identifier": m.identifier,
|
||||
"trust_level": m.trust_level,
|
||||
"repo": m.repo,
|
||||
"tags": list(m.tags or []),
|
||||
}
|
||||
for m in metas
|
||||
]
|
||||
|
||||
try:
|
||||
results = await asyncio.to_thread(_run)
|
||||
except Exception as exc:
|
||||
_log.exception("skills hub search failed")
|
||||
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
|
||||
return {"results": results}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user