diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 32c6e8109bd..d4ad1826e5e 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -364,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter): {"error": f"Unknown route: {route_name}"}, status=404 ) + # Disabled routes are kept in the subscriptions file (so the dashboard + # can re-enable them) but reject incoming events. Default-enabled: + # only an explicit ``enabled: false`` turns a route off, matching the + # mcp_servers ``enabled`` semantics. + if route_config.get("enabled", True) is False: + return web.json_response( + {"error": f"Route disabled: {route_name}"}, status=403 + ) + # ── Auth-before-body ───────────────────────────────────── # Check Content-Length before reading the full payload. content_length = request.content_length or 0 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1757cd2c2ee..dc37ecaf285 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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//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) # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index b77fa5ba55c..0190117be3c 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -74,6 +74,35 @@ class TestMcpEndpoints: r = self.client.post("/api/mcp/servers", json={"name": "bad"}) assert r.status_code == 400 + def test_enable_disable_toggle(self): + self.client.post("/api/mcp/servers", json={"name": "tog", "url": "u"}) + r = self.client.put("/api/mcp/servers/tog/enabled", json={"enabled": False}) + assert r.status_code == 200 and r.json()["enabled"] is False + srv = [ + s for s in self.client.get("/api/mcp/servers").json()["servers"] + if s["name"] == "tog" + ][0] + assert srv["enabled"] is False + # Toggling a missing server is a 404. + assert self.client.put( + "/api/mcp/servers/nope/enabled", json={"enabled": True} + ).status_code == 404 + + def test_catalog_lists_entries(self): + r = self.client.get("/api/mcp/catalog") + assert r.status_code == 200 + body = r.json() + assert "entries" in body and "diagnostics" in body + # The shipped optional-mcps/ catalog has at least one entry; each must + # carry the install/enabled status fields the UI relies on. + for e in body["entries"]: + assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e) + + def test_catalog_install_unknown_404(self): + r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"}) + assert r.status_code == 404 + + class TestCredentialPoolEndpoints: @pytest.fixture(autouse=True) @@ -190,6 +219,40 @@ class TestOpsEndpoints: save_config(cfg) data = self.client.get("/api/ops/hooks").json() assert data["hooks"][0]["command"] == "/bin/echo hi" + assert "valid_events" in data and len(data["valid_events"]) >= 1 + + def test_hook_create_and_delete(self): + # Create with consent approval. + r = self.client.post( + "/api/ops/hooks", + json={ + "event": "pre_tool_call", + "command": "/bin/echo created", + "matcher": "terminal", + "timeout": 7, + "approve": True, + }, + ) + assert r.status_code == 200 and r.json()["approved"] is True + + hooks = self.client.get("/api/ops/hooks").json()["hooks"] + created = [h for h in hooks if h["command"] == "/bin/echo created"] + assert created and created[0]["allowed"] is True + + # Unknown event rejected. + assert self.client.post( + "/api/ops/hooks", json={"event": "no_such_event", "command": "/x"} + ).status_code == 400 + + # Delete it. + r = self.client.request( + "DELETE", + "/api/ops/hooks", + json={"event": "pre_tool_call", "command": "/bin/echo created"}, + ) + assert r.status_code == 200 + hooks2 = self.client.get("/api/ops/hooks").json()["hooks"] + assert not [h for h in hooks2 if h["command"] == "/bin/echo created"] def test_checkpoints_list_empty(self): data = self.client.get("/api/ops/checkpoints").json() @@ -200,6 +263,131 @@ class TestOpsEndpoints: assert r.status_code == 404 +class TestSystemStatsEndpoint: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_stats_shape(self): + r = self.client.get("/api/system/stats") + assert r.status_code == 200 + s = r.json() + # Identity fields always present (stdlib-sourced). + for key in ("os", "arch", "hostname", "python_version", "hermes_version"): + assert key in s and s[key] + # psutil flag tells the UI whether the richer metrics are populated. + assert "psutil" in s + + +class TestCuratorEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_status_and_pause_toggle(self): + r = self.client.get("/api/curator") + assert r.status_code == 200 + body = r.json() + assert {"enabled", "paused", "interval_hours"} <= set(body) + # Pause then resume; the read reflects the write. + r = self.client.put("/api/curator/paused", json={"paused": True}) + assert r.status_code == 200 and r.json()["paused"] is True + assert self.client.get("/api/curator").json()["paused"] is True + r = self.client.put("/api/curator/paused", json={"paused": False}) + assert r.status_code == 200 and r.json()["paused"] is False + + +class TestPortalEndpoint: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_status_shape(self): + r = self.client.get("/api/portal") + assert r.status_code == 200 + body = r.json() + assert {"logged_in", "features", "subscription_url", "provider"} <= set(body) + assert isinstance(body["features"], list) + + +class TestSessionManagementEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + from hermes_state import SessionDB + + db = SessionDB() + db.create_session(session_id="sess-x", source="cli") + db.close() + + def test_stats_not_shadowed_by_session_id_route(self): + # /api/sessions/stats must resolve to the stats handler, not be captured + # as {session_id}="stats" by the parameterized route registered after it. + r = self.client.get("/api/sessions/stats") + assert r.status_code == 200 + body = r.json() + assert {"total", "active_store", "archived", "messages", "by_source"} <= set(body) + assert body["total"] >= 1 + + def test_rename(self): + r = self.client.patch("/api/sessions/sess-x", json={"title": "Renamed"}) + assert r.status_code == 200 and r.json()["title"] == "Renamed" + + def test_export(self): + r = self.client.get("/api/sessions/sess-x/export") + assert r.status_code == 200 and "messages" in r.json() + assert self.client.get("/api/sessions/nope/export").status_code == 404 + + def test_prune_validation(self): + r = self.client.post("/api/sessions/prune", json={"older_than_days": 9999}) + assert r.status_code == 200 and "removed" in r.json() + assert self.client.post( + "/api/sessions/prune", json={"older_than_days": 0} + ).status_code == 400 + + +class TestSkillsHubSearchEndpoint: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_empty_query_returns_empty(self): + # Empty query short-circuits (no network) and returns no results. + r = self.client.get("/api/skills/hub/search?q=") + assert r.status_code == 200 and r.json() == {"results": []} + + + + +class TestWebhookToggleEndpoint: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + # Enable the webhook platform so a subscription can be created. + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg.setdefault("platforms", {})["webhook"] = { + "enabled": True, + "extra": {"host": "0.0.0.0", "port": 8644}, + } + save_config(cfg) + + def test_create_toggle_disable(self): + r = self.client.post( + "/api/webhooks", json={"name": "hook1", "deliver": "log", "events": ["push"]} + ) + assert r.status_code == 200 and r.json()["enabled"] is True + r = self.client.put("/api/webhooks/hook1/enabled", json={"enabled": False}) + assert r.status_code == 200 and r.json()["enabled"] is False + subs = self.client.get("/api/webhooks").json()["subscriptions"] + assert subs[0]["enabled"] is False + assert self.client.put( + "/api/webhooks/nope/enabled", json={"enabled": True} + ).status_code == 404 + + + class TestAdminEndpointsAuthGate: """Every admin endpoint must sit behind the dashboard session-token gate.""" @@ -221,6 +409,9 @@ class TestAdminEndpointsAuthGate: "/api/memory", "/api/ops/hooks", "/api/ops/checkpoints", + "/api/curator", + "/api/portal", + "/api/system/stats", ], ) def test_gated(self, path): diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ae8e981727f..5b77ea2e924 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -238,6 +238,24 @@ export const api = { fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", }), + renameSession: (id: string, title: string) => + fetchJSON<{ ok: boolean; title: string }>( + `/api/sessions/${encodeURIComponent(id)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }, + ), + getSessionStats: () => fetchJSON("/api/sessions/stats"), + exportSessionUrl: (id: string) => + `/api/sessions/${encodeURIComponent(id)}/export`, + pruneSessions: (older_than_days: number, source?: string) => + fetchJSON<{ ok: boolean; removed: number }>("/api/sessions/prune", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ older_than_days, source }), + }), getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => { const qs = new URLSearchParams(); if (params.file) qs.set("file", params.file); @@ -311,6 +329,19 @@ export const api = { }), pauseCronJob: (id: string, profile = "default") => fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }), + updateCronJob: ( + id: string, + updates: { prompt?: string; schedule?: string; name?: string; deliver?: string }, + profile = "default", + ) => + fetchJSON( + `/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }, + ), resumeCronJob: (id: string, profile = "default") => fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }), triggerCronJob: (id: string, profile = "default") => @@ -522,6 +553,32 @@ export const api = { `/api/mcp/servers/${encodeURIComponent(name)}/test`, { method: "POST" }, ), + setMcpServerEnabled: (name: string, enabled: boolean) => + fetchJSON<{ ok: boolean; name: string; enabled: boolean }>( + `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ), + getMcpCatalog: () => + fetchJSON<{ entries: McpCatalogEntry[]; diagnostics: McpCatalogDiagnostic[] }>( + "/api/mcp/catalog", + ), + installMcpCatalogEntry: ( + name: string, + env: Record = {}, + enable = true, + ) => + fetchJSON<{ ok: boolean; name: string; background: boolean; action?: string }>( + "/api/mcp/catalog/install", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, env, enable }), + }, + ), // ── Admin: Pairing ────────────────────────────────────────────────── getPairing: () => fetchJSON("/api/pairing"), @@ -554,6 +611,15 @@ export const api = { fetchJSON<{ ok: boolean }>(`/api/webhooks/${encodeURIComponent(name)}`, { method: "DELETE", }), + setWebhookEnabled: (name: string, enabled: boolean) => + fetchJSON<{ ok: boolean; name: string; enabled: boolean }>( + `/api/webhooks/${encodeURIComponent(name)}/enabled`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ), // ── Admin: Credential pool ────────────────────────────────────────── getCredentialPool: () => @@ -616,6 +682,45 @@ export const api = { body: JSON.stringify({ archive }), }), getHooks: () => fetchJSON("/api/ops/hooks"), + createHook: (body: HookCreate) => + fetchJSON<{ ok: boolean; event: string; command: string; approved: boolean }>( + "/api/ops/hooks", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + deleteHook: (event: string, command: string) => + fetchJSON<{ ok: boolean }>("/api/ops/hooks", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event, command }), + }), + getSystemStats: () => fetchJSON("/api/system/stats"), + + // ── Admin: Curator ────────────────────────────────────────────────── + getCurator: () => fetchJSON("/api/curator"), + setCuratorPaused: (paused: boolean) => + fetchJSON<{ ok: boolean; paused: boolean }>("/api/curator/paused", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paused }), + }), + runCurator: () => + fetchJSON("/api/curator/run", { method: "POST" }), + + // ── Admin: Portal ─────────────────────────────────────────────────── + getPortal: () => fetchJSON("/api/portal"), + + // ── Admin: Diagnostics (backgrounded) ─────────────────────────────── + runPromptSize: () => + fetchJSON("/api/ops/prompt-size", { method: "POST" }), + runDump: () => fetchJSON("/api/ops/dump", { method: "POST" }), + runConfigMigrate: () => + fetchJSON("/api/ops/config-migrate", { method: "POST" }), + + getCheckpoints: () => fetchJSON("/api/ops/checkpoints"), pruneCheckpoints: () => fetchJSON("/api/ops/checkpoints/prune", { method: "POST" }), @@ -635,6 +740,10 @@ export const api = { }), updateSkillsFromHub: () => fetchJSON("/api/skills/hub/update", { method: "POST" }), + searchSkillsHub: (q: string, source = "all", limit = 20) => + fetchJSON<{ results: SkillHubResult[] }>( + `/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`, + ), }; /** Identity payload returned by ``GET /api/auth/me`` (Phase 7). @@ -663,6 +772,24 @@ export interface ActionResponse { update_command?: string; } +export interface SessionStoreStats { + total: number; + active_store: number; + archived: number; + messages: number; + by_source: Record; +} + +export interface SkillHubResult { + name: string; + description: string; + source: string; + identifier: string; + trust_level: string; + repo: string | null; + tags: string[]; +} + // ── Admin types ─────────────────────────────────────────────────────── export interface McpServer { @@ -677,6 +804,25 @@ export interface McpServer { tools: string[] | null; } +export interface McpCatalogEntry { + name: string; + description: string; + source: string; + transport: "http" | "stdio"; + auth_type: "api_key" | "oauth" | "none"; + required_env: Array<{ name: string; prompt: string; required: boolean }>; + needs_install: boolean; + installed: boolean; + enabled: boolean; +} + +export interface McpCatalogDiagnostic { + name: string; + kind: string; + message: string; +} + + export interface McpServerCreate { name: string; url?: string; @@ -716,6 +862,7 @@ export interface WebhookRoute { created_at: string | null; url: string; secret_set: boolean; + enabled: boolean; } export interface WebhooksResponse { @@ -771,11 +918,65 @@ export interface HookEntry { command: string | null; timeout: number | null; allowed: boolean; + approved_at?: string | null; + executable?: boolean; } export interface HooksResponse { hooks: HookEntry[]; - allowlist: string[]; + valid_events: string[]; +} + +export interface HookCreate { + event: string; + command: string; + matcher?: string; + timeout?: number; + approve?: boolean; +} + +export interface SystemStats { + os: string; + os_release: string; + os_version: string; + platform: string; + arch: string; + hostname: string; + python_version: string; + python_impl: string; + hermes_version: string; + cpu_count: number | null; + psutil: boolean; + cpu_percent?: number; + load_avg?: number[]; + uptime_seconds?: number; + memory?: { total: number; available: number; used: number; percent: number }; + disk?: { total: number; used: number; free: number; percent: number }; + process?: { pid: number; rss: number; create_time: number; num_threads: number }; +} + +export interface CuratorStatus { + enabled: boolean; + paused: boolean; + interval_hours: number | null; + last_run_at: string | null; + min_idle_hours: number | null; + stale_after_days: number | null; + archive_after_days: number | null; +} + +export interface PortalFeature { + label: string; + state: string; +} + +export interface PortalStatus { + logged_in: boolean; + portal_url: string | null; + inference_url: string | null; + provider: string; + subscription_url: string; + features: PortalFeature[]; } export interface CheckpointSession { diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 85af87489e5..741d6d5bec1 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react"; -import { Clock, Pause, Play, Trash2, X, Zap } from "lucide-react"; +import { Clock, Pause, Pencil, Play, 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"; @@ -119,6 +119,29 @@ export default function CronPage() { const [creating, setCreating] = useState(false); const createProfile = selectedProfile === "all" ? "default" : selectedProfile; + // Edit job modal state + const [editJob, setEditJob] = useState(null); + const [editPrompt, setEditPrompt] = useState(""); + const [editSchedule, setEditSchedule] = useState(""); + const [editName, setEditName] = useState(""); + const [editDeliver, setEditDeliver] = useState("local"); + const [saving, setSaving] = useState(false); + const closeEditModal = useCallback(() => setEditJob(null), []); + const editModalRef = useModalBehavior({ + open: editJob !== null, + onClose: closeEditModal, + }); + + const openEditModal = useCallback((job: CronJob) => { + setEditJob(job); + setEditPrompt(getJobPrompt(job)); + setEditSchedule( + asText(job.schedule?.expr) || asText(job.schedule_display) || "", + ); + setEditName(getJobName(job)); + setEditDeliver(asText(job.deliver) || "local"); + }, []); + const loadJobs = useCallback(() => { api .getCronJobs(selectedProfile) @@ -168,6 +191,34 @@ export default function CronPage() { } }; + const handleEdit = async () => { + if (!editJob) return; + if (!editPrompt.trim() || !editSchedule.trim()) { + showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error"); + return; + } + setSaving(true); + try { + await api.updateCronJob( + editJob.id, + { + prompt: editPrompt.trim(), + schedule: editSchedule.trim(), + name: editName.trim(), + deliver: editDeliver, + }, + getJobProfile(editJob), + ); + showToast("Saved changes ✓", "success"); + setEditJob(null); + loadJobs(); + } catch (e) { + showToast(`${t.config.failedToSave}: ${e}`, "error"); + } finally { + setSaving(false); + } + }; + const handlePauseResume = async (job: CronJob) => { try { const isPaused = getJobState(job) === "paused"; @@ -394,6 +445,112 @@ export default function CronPage() { )} + {/* Edit job modal */} + {editJob && ( +
e.target === e.currentTarget && setEditJob(null)} + role="dialog" + aria-modal="true" + aria-labelledby="edit-cron-title" + > +
+ + +
+

+ Edit job +

+
+ +
+
+ + setEditName(e.target.value)} + /> +
+ +
+ +