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.
This commit is contained in:
Teknium
2026-06-06 07:45:36 -07:00
committed by GitHub
parent e6de6dd559
commit 2bf0a6e760
7 changed files with 866 additions and 0 deletions
@@ -794,3 +794,132 @@ class TestDebugShareEndpoint:
)
assert r.status_code == 401
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"