hermes-bsd/tests/hermes_cli/test_dashboard_admin_endpoints.py

1086 lines
39 KiB
Python
Raw Normal View History

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.
2026-06-01 02:58:02 -07:00
"""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
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.
2026-06-01 21:16:11 -07:00
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 plus the inspection detail
# the dashboard renders (transport target, install source, guidance) so
# users can vet an entry before installing.
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.
2026-06-01 21:16:11 -07:00
for e in body["entries"]:
assert {
"name",
"transport",
"auth_type",
"installed",
"enabled",
"needs_install",
"command",
"args",
"url",
"install_url",
"install_ref",
"bootstrap",
"default_enabled",
"post_install",
} <= set(e)
# http entries expose a url; stdio entries expose a command.
if e["transport"] == "http":
assert e["url"]
elif e["transport"] == "stdio":
assert e["command"]
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.
2026-06-01 21:16:11 -07:00
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
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.
2026-06-01 02:58:02 -07:00
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
2026-06-11 14:54:47 +10:00
def test_enable_platform_starts_gateway_restart(self, monkeypatch):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config
ws._ACTION_PROCS.pop("gateway-restart", None)
restart_calls = []
class FakeRestartProc:
pid = 4242
def fake_spawn_action(subcommand, name):
restart_calls.append((subcommand, name))
return FakeRestartProc()
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action)
r = self.client.post("/api/webhooks/enable")
assert r.status_code == 200
assert r.json() == {
"ok": True,
"platform": "webhook",
"enabled": True,
"needs_restart": False,
"restart_started": True,
"restart_action": "gateway-restart",
"restart_pid": 4242,
}
assert restart_calls == [(["gateway", "restart"], "gateway-restart")]
assert load_config()["platforms"]["webhook"]["enabled"] is True
assert self.client.get("/api/webhooks").json()["enabled"] is True
def test_enable_platform_reports_restart_failure_after_save(self, monkeypatch):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config
ws._ACTION_PROCS.pop("gateway-restart", None)
def fail_spawn_action(subcommand, name):
assert subcommand == ["gateway", "restart"]
assert name == "gateway-restart"
raise RuntimeError("supervisor unavailable")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
r = self.client.post("/api/webhooks/enable")
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["platform"] == "webhook"
assert data["enabled"] is True
assert data["needs_restart"] is True
assert data["restart_started"] is False
assert "supervisor unavailable" in data["restart_error"]
assert load_config()["platforms"]["webhook"]["enabled"] is True
def test_enable_platform_reuses_inflight_gateway_restart(self, monkeypatch):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config
ws._ACTION_PROCS.pop("gateway-restart", None)
class FakeRunningProc:
pid = 5151
def poll(self):
return None
monkeypatch.setitem(ws._ACTION_PROCS, "gateway-restart", FakeRunningProc())
def fail_spawn_action(subcommand, name):
raise AssertionError("must not spawn a second concurrent restart")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
r = self.client.post("/api/webhooks/enable")
assert r.status_code == 200
data = r.json()
assert data["needs_restart"] is False
assert data["restart_started"] is True
assert data["restart_pid"] == 5151
assert load_config()["platforms"]["webhook"]["enabled"] is True
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.
2026-06-01 02:58:02 -07:00
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"
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.
2026-06-01 21:16:11 -07:00
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"]
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.
2026-06-01 02:58:02 -07:00
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
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.
2026-06-01 21:16:11 -07:00
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):
feat(dashboard): rehaul Skills hub browser — connected hubs, featured, preview + security scan (#40384) The Browse-hub tab was a blank search box with sparse result cards (name + source + one Install button), no way to read a skill before installing, no visual security scan, and no indication it was even connected to any hubs. Backend (web_server.py): - GET /api/skills/hub/sources — lists the configured hubs (label + trust tier + GitHub rate-limit + index availability) and featured skills pulled from the centralized index (zero extra API calls), plus installed-skill provenance so the UI can mark already-installed results. - GET /api/skills/hub/preview — fetches a skill's SKILL.md text + file manifest WITHOUT installing (decodes byte-stored text, masks binaries). - GET /api/skills/hub/scan — runs the SAME quarantine + scan_skill + should_allow_install pipeline the CLI installer uses, then cleans up quarantine, returning verdict / per-finding detail / severity tally / install-policy decision. - search now returns per-source counts + timed-out sources + installed map. Frontend (SkillsPage HubBrowser): - Landing state: connected-hubs strip + featured skill grid (no more blank page). - Rich cards: trust-level color coding, source, tags, identifier, Details + Install (or Installed state). - Detail dialog: read the actual SKILL.md, on-demand visual security scan (verdict pill, severity tally, per-finding list, allow/block policy), GitHub repo link. - Search meta line: result count + timing + per-source breakdown (the 'feels slow / no feedback' complaint). Tests: 4 new endpoint test classes (sources/preview/scan + updated search shape) in test_dashboard_admin_endpoints.py.
2026-06-06 02:44:50 -07:00
# Empty query short-circuits (no network) and returns the enriched
# empty shape (results + per-source counts + timeouts + installed map).
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.
2026-06-01 21:16:11 -07:00
r = self.client.get("/api/skills/hub/search?q=")
feat(dashboard): rehaul Skills hub browser — connected hubs, featured, preview + security scan (#40384) The Browse-hub tab was a blank search box with sparse result cards (name + source + one Install button), no way to read a skill before installing, no visual security scan, and no indication it was even connected to any hubs. Backend (web_server.py): - GET /api/skills/hub/sources — lists the configured hubs (label + trust tier + GitHub rate-limit + index availability) and featured skills pulled from the centralized index (zero extra API calls), plus installed-skill provenance so the UI can mark already-installed results. - GET /api/skills/hub/preview — fetches a skill's SKILL.md text + file manifest WITHOUT installing (decodes byte-stored text, masks binaries). - GET /api/skills/hub/scan — runs the SAME quarantine + scan_skill + should_allow_install pipeline the CLI installer uses, then cleans up quarantine, returning verdict / per-finding detail / severity tally / install-policy decision. - search now returns per-source counts + timed-out sources + installed map. Frontend (SkillsPage HubBrowser): - Landing state: connected-hubs strip + featured skill grid (no more blank page). - Rich cards: trust-level color coding, source, tags, identifier, Details + Install (or Installed state). - Detail dialog: read the actual SKILL.md, on-demand visual security scan (verdict pill, severity tally, per-finding list, allow/block policy), GitHub repo link. - Search meta line: result count + timing + per-source breakdown (the 'feels slow / no feedback' complaint). Tests: 4 new endpoint test classes (sources/preview/scan + updated search shape) in test_dashboard_admin_endpoints.py.
2026-06-06 02:44:50 -07:00
assert r.status_code == 200
body = r.json()
assert body["results"] == []
assert body["source_counts"] == {}
assert body["timed_out"] == []
assert body["installed"] == {}
class _FakeMeta:
"""Minimal SkillMeta stand-in for monkeypatched source search."""
def __init__(self, identifier, trust_level="community", source="github"):
self.name = identifier.rsplit("/", 1)[-1]
self.description = "desc"
self.source = source
self.identifier = identifier
self.trust_level = trust_level
self.repo = "owner/repo"
self.tags = ["a", "b"]
# Used by the preview endpoint's getattr() fallbacks.
self.files = {}
class _FakeBundle:
def __init__(self, identifier, source="github", trust_level="community"):
self.name = identifier.rsplit("/", 1)[-1]
self.identifier = identifier
self.source = source
self.trust_level = trust_level
self.description = "desc"
self.repo = "owner/repo"
self.tags = ["a", "b"]
# Mix str + bytes to exercise the decode-or-placeholder branch.
self.files = {
"SKILL.md": b"---\nname: x\n---\nbody text",
"icon.png": b"\xff\xd8\xff\xe0binary",
"notes.txt": "plain string content",
}
self.metadata = {}
class TestSkillsHubSourcesEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_sources_lists_configured_hubs(self, monkeypatch):
# The endpoint should enumerate the configured hub sources without
# requiring any live network — monkeypatch the router.
class _Src:
is_available = False
def __init__(self, sid):
self._sid = sid
def source_id(self):
return self._sid
def search(self, q, limit=10):
return [_FakeMeta("hermes-index/featured-skill", "trusted")]
def _fake_router():
srcs = [_Src("official"), _Src("github")]
# hermes-index source advertises availability + featured search.
idx = _Src("hermes-index")
idx.is_available = True
srcs.insert(1, idx)
return srcs
monkeypatch.setattr(
"tools.skills_hub.create_source_router", _fake_router
)
r = self.client.get("/api/skills/hub/sources")
assert r.status_code == 200
body = r.json()
ids = {s["id"] for s in body["sources"]}
assert {"official", "github", "hermes-index"} <= ids
# Every source carries a human label.
assert all(s.get("label") for s in body["sources"])
assert body["index_available"] is True
# Featured pulled from the index (zero extra API calls).
assert len(body["featured"]) == 1
assert body["featured"][0]["trust_level"] == "trusted"
assert isinstance(body["installed"], dict)
class TestSkillsHubPreviewEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_preview_requires_identifier(self):
r = self.client.get("/api/skills/hub/preview?identifier=")
assert r.status_code == 400
def test_preview_returns_skill_md_text(self, monkeypatch):
monkeypatch.setattr(
"tools.skills_hub.create_source_router", lambda: []
)
bundle = _FakeBundle("github/owner/repo/x")
meta = _FakeMeta("github/owner/repo/x")
monkeypatch.setattr(
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
lambda ident, sources: (meta, bundle, None),
)
r = self.client.get(
"/api/skills/hub/preview?identifier=github/owner/repo/x"
)
assert r.status_code == 200
body = r.json()
# Bytes-stored SKILL.md decodes to text.
assert "body text" in body["skill_md"]
# Binary file is masked, text files decode.
assert "icon.png" in body["files"]
assert sorted(body["files"]) == ["SKILL.md", "icon.png", "notes.txt"]
def test_preview_404_when_unresolved(self, monkeypatch):
monkeypatch.setattr(
"tools.skills_hub.create_source_router", lambda: []
)
monkeypatch.setattr(
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
lambda ident, sources: (None, None, None),
)
r = self.client.get("/api/skills/hub/preview?identifier=nope/x")
assert r.status_code == 404
class TestSkillsHubScanEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_scan_requires_identifier(self):
r = self.client.get("/api/skills/hub/scan?identifier=")
assert r.status_code == 400
def test_scan_returns_verdict_and_policy(self, monkeypatch):
from tools.skills_guard import ScanResult, Finding
monkeypatch.setattr(
"tools.skills_hub.create_source_router", lambda: []
)
bundle = _FakeBundle("github/owner/repo/x", trust_level="community")
monkeypatch.setattr(
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
lambda ident, sources: (None, bundle, None),
)
from pathlib import Path
monkeypatch.setattr(
"tools.skills_hub.quarantine_bundle", lambda b: Path("/tmp/_fake_q")
)
fake_result = ScanResult(
skill_name="x",
source="github/owner/repo/x",
trust_level="community",
verdict="caution",
findings=[
Finding(
pattern_id="p",
severity="high",
category="exfiltration",
file="SKILL.md",
line=10,
match="m",
description="leaks data",
)
],
summary="s",
)
monkeypatch.setattr(
"tools.skills_guard.scan_skill",
lambda path, source="community": fake_result,
)
# Avoid touching the filesystem during cleanup.
monkeypatch.setattr("shutil.rmtree", lambda *a, **k: None)
r = self.client.get(
"/api/skills/hub/scan?identifier=github/owner/repo/x"
)
assert r.status_code == 200
body = r.json()
assert body["verdict"] == "caution"
assert body["trust_level"] == "community"
# community + caution => blocked by install policy.
assert body["policy"] == "block"
assert body["severity_counts"]["high"] == 1
assert body["findings"][0]["category"] == "exfiltration"
assert body["findings"][0]["file"] == "SKILL.md"
def test_scan_404_when_no_bundle(self, monkeypatch):
monkeypatch.setattr(
"tools.skills_hub.create_source_router", lambda: []
)
monkeypatch.setattr(
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
lambda ident, sources: (None, None, None),
)
r = self.client.get("/api/skills/hub/scan?identifier=nope/x")
assert r.status_code == 404
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.
2026-06-01 21:16:11 -07:00
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
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.
2026-06-01 02:58:02 -07:00
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",
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.
2026-06-01 21:16:11 -07:00
"/api/curator",
"/api/portal",
"/api/system/stats",
"/api/hermes/update/check",
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.
2026-06-01 02:58:02 -07:00
],
)
def test_gated(self, path):
resp = self.client.get(path)
assert resp.status_code in (401, 403)
2026-06-11 14:54:47 +10:00
def test_webhooks_enable_post_gated(self):
resp = self.client.post("/api/webhooks/enable")
assert resp.status_code in (401, 403)
class TestUpdateCheckEndpoint:
"""``GET /api/hermes/update/check`` reports availability without applying.
Powers the dashboard's check-before-you-update flow: the System page
shows the commit-behind count and asks the user to confirm before
``POST /api/hermes/update`` runs ``hermes update``.
"""
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_git_install_reports_behind_count(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
# Stub the shared checker so the contract is deterministic (no network).
import hermes_cli.banner as banner
monkeypatch.setattr(banner, "check_for_updates", lambda: 5)
r = self.client.get("/api/hermes/update/check")
assert r.status_code == 200
body = r.json()
assert {
"install_method",
"current_version",
"behind",
"update_available",
"can_apply",
"update_command",
"message",
} <= set(body)
assert body["install_method"] == "git"
assert body["behind"] == 5
assert body["update_available"] is True
# git/pip installs can apply the update in place from the dashboard.
assert body["can_apply"] is True
def test_up_to_date(self, monkeypatch):
import hermes_cli.web_server as ws
import hermes_cli.banner as banner
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
monkeypatch.setattr(banner, "check_for_updates", lambda: 0)
body = self.client.get("/api/hermes/update/check").json()
assert body["behind"] == 0
assert body["update_available"] is False
def test_docker_is_not_applyable(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "docker")
body = self.client.get("/api/hermes/update/check").json()
# Docker images are immutable — the dashboard can't apply an update.
assert body["can_apply"] is False
assert body["message"]
assert body["behind"] is None
def test_managed_runtime_dashboard_is_not_applyable(self, monkeypatch):
2026-06-16 11:24:14 +10:00
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "_dashboard_local_update_managed_externally", lambda: True)
2026-06-16 11:24:14 +10:00
monkeypatch.setattr(
ws,
"detect_install_method",
lambda *a, **k: pytest.fail(
"managed runtime update check should not probe install method"
2026-06-16 11:24:14 +10:00
),
)
body = self.client.get("/api/hermes/update/check").json()
assert body["install_method"] == "managed-runtime"
2026-06-16 11:24:14 +10:00
assert body["can_apply"] is False
assert body["update_available"] is False
assert body["behind"] is None
assert "managed outside this dashboard" in body["message"]
2026-06-16 11:24:14 +10:00
def test_check_failure_is_soft(self, monkeypatch):
import hermes_cli.web_server as ws
import hermes_cli.banner as banner
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
def _boom():
raise RuntimeError("offline")
monkeypatch.setattr(banner, "check_for_updates", _boom)
# A failed check must not 500 — it returns behind=null with guidance.
r = self.client.get("/api/hermes/update/check")
assert r.status_code == 200
body = r.json()
assert body["behind"] is None
assert body["update_available"] is False
assert body["message"]
def test_git_behind_includes_commits(self, monkeypatch):
import hermes_cli.web_server as ws
import hermes_cli.banner as banner
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
monkeypatch.setattr(banner, "check_for_updates", lambda: 3)
monkeypatch.setattr(
ws,
"_recent_upstream_commits",
lambda n=20: [
{"sha": "abc1234", "summary": "feat: x", "author": "a", "at": 1},
],
)
body = self.client.get("/api/hermes/update/check").json()
# The desktop overlay renders this as the "what's changed" list.
assert isinstance(body["commits"], list)
assert body["commits"][0]["sha"] == "abc1234"
assert body["commits"][0]["summary"] == "feat: x"
def test_up_to_date_omits_commits(self, monkeypatch):
import hermes_cli.web_server as ws
import hermes_cli.banner as banner
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
monkeypatch.setattr(banner, "check_for_updates", lambda: 0)
body = self.client.get("/api/hermes/update/check").json()
# No commits list when there's nothing to show (additive, non-breaking).
assert body.get("commits", []) == []
feat(dashboard): add Debug Share to the System page (#38600) * Port from google-gemini/gemini-cli#21541: back up corrupted config.yaml When config.yaml fails to parse, load_config() silently falls back to DEFAULT_CONFIG and leaves the broken file on disk. If the user then re-runs the setup wizard or hermes config set (both rewrite config.yaml), their broken-but-recoverable overrides are lost for good. Adapts the policy-file recovery from gemini-cli#21541: on the first parse warning for a given broken file, snapshot it to config.yaml.corrupt.<ts>.bak (best-effort, symlink-guarded, size-deduped) and tell the user where it landed. Unlike Gemini's version we deliberately do NOT reset config.yaml to a clean state — hermes never silently mutates user config, and leaving it means a hand-fixed file is re-read on the next load. Tests: 3 new cases (backup created + content preserved + original untouched; same-size backup dedup; symlink not copied). E2E verified with isolated HERMES_HOME and a real tab-indented broken config. * feat(dashboard): add Debug Share to the System page Surface `hermes debug share` in the dashboard. The System > Operations section gets a dedicated card that uploads a redacted report + full logs and returns the paste URLs as real, copyable links instead of a log tail. - debug.py: factor a pure build_debug_share() returning structured {urls, failures, redacted, auto_delete_seconds}; run_debug_share now calls it (CLI output unchanged). - web_server.py: POST /api/ops/debug-share runs the share core in a worker thread and returns the structured payload synchronously (the URLs are the whole point — not a backgrounded action). - api.ts: runDebugShare() + DebugShareResponse. - SystemPage.tsx: share card with a redaction toggle (on by default), per-link + copy-all buttons, and the 6h auto-delete countdown. - tests: build_debug_share core + endpoint (redact toggle, failure 502, token gate).
2026-06-03 19:37:04 -07:00
class TestDebugShareEndpoint:
"""POST /api/ops/debug-share returns the paste URLs synchronously so the
dashboard can render them as copyable links (not a backgrounded log tail)."""
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, self.header = _client()
from hermes_constants import get_hermes_home
logs = get_hermes_home() / "logs"
logs.mkdir(parents=True, exist_ok=True)
(logs / "agent.log").write_text("agent line\n")
(logs / "errors.log").write_text("err line\n")
(logs / "gateway.log").write_text("gw line\n")
def test_returns_structured_urls(self, monkeypatch):
import hermes_cli.debug as dbg
count = [0]
def _upload(content, expiry_days=7):
count[0] += 1
return f"https://paste.rs/p{count[0]}"
monkeypatch.setattr(dbg, "upload_to_pastebin", _upload)
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
r = self.client.post("/api/ops/debug-share", json={"redact": True})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert "Report" in body["urls"]
assert body["redacted"] is True
assert body["auto_delete_seconds"] == 21600
assert isinstance(body["failures"], list)
def test_redact_false_is_honored(self, monkeypatch):
import hermes_cli.debug as dbg
monkeypatch.setattr(
dbg, "upload_to_pastebin", lambda c, expiry_days=7: "https://paste.rs/x"
)
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
r = self.client.post("/api/ops/debug-share", json={"redact": False})
assert r.status_code == 200
assert r.json()["redacted"] is False
def test_default_body_redacts(self, monkeypatch):
import hermes_cli.debug as dbg
monkeypatch.setattr(
dbg, "upload_to_pastebin", lambda c, expiry_days=7: "https://paste.rs/x"
)
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
# No JSON body at all — should default redact=True.
r = self.client.post("/api/ops/debug-share")
assert r.status_code == 200
assert r.json()["redacted"] is True
def test_upload_failure_returns_502(self, monkeypatch):
import hermes_cli.debug as dbg
monkeypatch.setattr(
dbg,
"upload_to_pastebin",
lambda c, expiry_days=7: (_ for _ in ()).throw(RuntimeError("down")),
)
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
r = self.client.post("/api/ops/debug-share", json={"redact": True})
assert r.status_code == 502
def test_requires_session_token(self):
# Drop the token header and confirm the global auth gate rejects it.
bare = self.client
r = bare.post(
"/api/ops/debug-share",
json={"redact": True},
headers={self.header: "wrong-token"},
)
assert r.status_code == 401
feat(dashboard): full tool backend configuration in the GUI (#40418) Replicate the `hermes tools` configurator in the dashboard Skills → Toolsets view. Each toolset now opens a config drawer that covers the full lifecycle the CLI offers: enable/disable, pick a provider/backend, enter and save API keys, and run a provider's post-setup install hook with a live log tail. The toolset view was previously read+toggle only — the provider matrix and key-status endpoints existed but the page never called them, and there was no way to save a key or run a backend install (npm/pip/binary) from the browser. Backend: - New CLI subcommand `hermes tools post-setup <KEY>` — non-interactive, scriptable target that runs a provider's install hook (agent_browser, camofox, cua_driver, kittentts, piper, ddgs, spotify, langfuse, xai_grok). Validated against valid_post_setup_keys() so an arbitrary key can't drive _run_post_setup. - PUT /api/tools/toolsets/{name}/env — save API keys to ~/.hermes/.env via save_env_value (same store the CLI writes), validated against the toolset category's env-var allowlist; blank values skipped. - POST /api/tools/toolsets/{name}/post-setup — spawn-action that runs `hermes tools post-setup <key>`; frontend tails the log via the existing /api/actions/tools-post-setup/status. Registered in _ACTION_LOG_FILES. Frontend: - New ToolsetConfigDrawer component (provider radios, password key inputs with saved-state, get-a-key links, Run-setup + live install log). Toolset cards get a Configure button + the drawer also exposes the enable toggle. - api.ts: toggleToolset, getToolsetConfig, selectToolsetProvider, saveToolsetEnv, runToolsetPostSetup + ToolsetConfig/Provider/EnvVar/ EnvResult types. Validation: 56 admin-endpoint tests pass (10 new: env save w/ CLI parity + allowlist reject + blank-skip, post-setup spawn validation, auth gate); 232 web_server tests pass; web npm run build + eslint clean; HTTP E2E exercises save-key (CLI reads it back) and spawn+poll post-setup to exit 0.
2026-06-06 07:45:36 -07:00
class TestToolsConfigEndpoints:
"""Provider selection, API-key save, and post-setup spawn for toolsets —
the dashboard surface that replicates the `hermes tools` configurator."""
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, self.header = _client()
def test_list_toolsets_shape(self):
r = self.client.get("/api/tools/toolsets")
assert r.status_code == 200
rows = r.json()
assert isinstance(rows, list) and rows
row = rows[0]
for k in ("name", "label", "enabled", "configured", "tools"):
assert k in row
def test_toolset_config_provider_matrix(self):
# `web` has a TOOL_CATEGORIES entry → providers list populated.
r = self.client.get("/api/tools/toolsets/web/config")
assert r.status_code == 200
body = r.json()
assert body["has_category"] is True
assert isinstance(body["providers"], list)
def test_unknown_toolset_config_400(self):
r = self.client.get("/api/tools/toolsets/not_a_toolset/config")
assert r.status_code == 400
def test_save_env_writes_key_and_validates_allowlist(self):
from hermes_cli.config import get_env_value
cfg = self.client.get("/api/tools/toolsets/web/config").json()
# Find a real env-var key from the visible provider matrix.
key = None
for prov in cfg["providers"]:
for e in prov.get("env_vars", []):
key = e["key"]
break
if key:
break
if not key:
pytest.skip("no env-var-bearing web provider in this build")
r = self.client.put(
"/api/tools/toolsets/web/env", json={"env": {key: "test-secret-123"}}
)
assert r.status_code == 200, r.text
body = r.json()
assert key in body["saved"]
assert body["is_set"][key] is True
# CLI-config parity: the key landed in the .env store the CLI reads.
assert get_env_value(key) == "test-secret-123"
def test_save_env_rejects_unknown_key(self):
r = self.client.put(
"/api/tools/toolsets/web/env",
json={"env": {"TOTALLY_BOGUS_KEY": "x"}},
)
assert r.status_code == 400
def test_save_env_blank_value_skipped(self):
cfg = self.client.get("/api/tools/toolsets/web/config").json()
key = None
for prov in cfg["providers"]:
for e in prov.get("env_vars", []):
key = e["key"]
break
if key:
break
if not key:
pytest.skip("no env-var-bearing web provider in this build")
r = self.client.put(
"/api/tools/toolsets/web/env", json={"env": {key: " "}}
)
assert r.status_code == 200
assert key in r.json()["skipped"]
def test_post_setup_unknown_key_400(self):
r = self.client.post(
"/api/tools/toolsets/browser/post-setup", json={"key": "bogus"}
)
assert r.status_code == 400
def test_post_setup_unknown_toolset_400(self):
r = self.client.post(
"/api/tools/toolsets/not_a_toolset/post-setup",
json={"key": "agent_browser"},
)
assert r.status_code == 400
def test_post_setup_spawns_action(self, monkeypatch):
import hermes_cli.web_server as ws
spawned = {}
class _FakeProc:
pid = 4321
def _fake_spawn(subcommand, name):
spawned["subcommand"] = subcommand
spawned["name"] = name
return _FakeProc()
monkeypatch.setattr(ws, "_spawn_hermes_action", _fake_spawn)
r = self.client.post(
"/api/tools/toolsets/browser/post-setup",
json={"key": "agent_browser"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["name"] == "tools-post-setup"
assert body["pid"] == 4321
assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"]
def test_endpoints_require_session_token(self):
for method, path, payload in [
("get", "/api/tools/toolsets/web/config", None),
("put", "/api/tools/toolsets/web/env", {"env": {}}),
("post", "/api/tools/toolsets/web/post-setup", {"key": "ddgs"}),
]:
fn = getattr(self.client, method)
kwargs = {"headers": {self.header: "wrong-token"}}
if payload is not None:
kwargs["json"] = payload
r = fn(path, **kwargs)
assert r.status_code == 401, f"{method} {path} not gated"