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:
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user