feat(dashboard): unify multi-profile management — one machine dashboard, global profile switcher

The dashboard becomes a machine-level management surface with one
write-target selector, replacing per-profile dashboard fragmentation.

Backend:
- profile param (query or body) on /api/config (get/put/raw), /api/env
  (get/put/delete/reveal), /api/mcp/servers (list/add/remove/test/enabled),
  /api/mcp/catalog (list/install), /api/model/info, /api/model/set —
  all scoped through the existing _profile_scope() context manager
- model/set restructured: expensive-model warning (await) runs before the
  scope; the config write runs sync inside the scope in a worker thread
- MCP catalog installs + git-bootstrap entries spawn 'hermes -p <profile>'
- chat PTY: ?profile= on /api/pty points the child's HERMES_HOME at the
  profile dir (its own gateway subprocess, config/skills/memory/state.db
  all profile-bound); in-process gateway attach skipped when scoped

CLI launch unification:
- '<profile> dashboard' routes to the machine dashboard: attach (open
  browser at ?profile=) when one is listening, else re-exec pinned to the
  default profile with --open-profile preselecting the launcher
- --isolated preserves the old dedicated per-profile server behavior
- start_server(initial_profile=...) appends ?profile= to the auto-open URL

Frontend:
- ProfileProvider + sidebar ProfileSwitcher: ONE global selector, URL-
  persisted (?profile=), mirrored into fetchJSON which auto-appends the
  param to the scoped endpoint families (explicit params win)
- app-wide amber banner names the managed profile
- SkillsPage's page-local selector (from the skills-scoping PR) folded
  into the global context — single source of truth
- ChatPage threads the scope into the PTY WS URL; switching profiles
  remounts the terminal into a fresh scoped session

Omitted profile keeps legacy behavior everywhere.
This commit is contained in:
Teknium
2026-06-10 22:00:06 -07:00
parent 3e74f75e41
commit f02302738d
17 changed files with 1044 additions and 291 deletions
@@ -0,0 +1,114 @@
"""Tests for the unified profile→machine dashboard launch routing.
`<profile> dashboard` routes to ONE machine-level dashboard instead of
spawning a per-profile server: attach (open browser at ?profile=) when one
is already listening, else re-exec as the machine dashboard with the
launching profile preselected. `--isolated` opts out.
"""
import sys
import types
import pytest
@pytest.fixture
def main_mod():
import hermes_cli.main as main_mod
return main_mod
def _args(**kw):
defaults = dict(
status=False, stop=False, host="127.0.0.1", port=9119,
no_open=True, insecure=False, skip_build=False,
isolated=False, open_profile="",
)
defaults.update(kw)
return types.SimpleNamespace(**defaults)
class TestUnifiedDashboardRouting:
def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
with pytest.raises(SystemExit) as exc:
main_mod.cmd_dashboard(_args())
assert exc.value.code == 0
assert execs == [] # attached, never re-exec'd
def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False)
execs = []
def fake_exec(exe, argv, env):
execs.append((exe, argv, env))
raise SystemExit(0) # execvpe never returns
monkeypatch.setattr(main_mod.os, "execvpe", fake_exec)
with pytest.raises(SystemExit):
main_mod.cmd_dashboard(_args())
assert len(execs) == 1
exe, argv, env = execs[0]
assert exe == sys.executable
# Pinned to the default profile + launching profile preselected.
assert "-p" in argv and argv[argv.index("-p") + 1] == "default"
assert "--open-profile" in argv
assert argv[argv.index("--open-profile") + 1] == "worker_x"
# Profile HERMES_HOME dropped so the child binds the machine root.
assert "HERMES_HOME" not in env
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or True,
)
# With --isolated the routing block is skipped entirely; the command
# proceeds to dependency checks. Make the first post-routing step
# bail so the test doesn't actually start a server.
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(isolated=True))
assert listening_calls == []
def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or True,
)
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args())
assert listening_calls == []
def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch):
"""The re-exec'd child carries --open-profile; the guard must treat
that as 'already routed' and never re-exec again (no exec loop)."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
assert execs == []
+8 -8
View File
@@ -4441,7 +4441,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
from starlette.websockets import WebSocketDisconnect
@@ -4454,7 +4454,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
from starlette.websockets import WebSocketDisconnect
@@ -4467,7 +4467,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (
lambda resume=None, sidecar_url=None, profile=None: (
["/bin/sh", "-c", "printf hermes-ws-ok"],
None,
None,
@@ -4497,7 +4497,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
with self.client.websocket_connect(self._url()) as conn:
conn.send_bytes(b"round-trip-payload\n")
@@ -4530,7 +4530,7 @@ class TestPtyWebSocket:
self.ws_module,
"_resolve_chat_argv",
# sleep gives the test time to push the resize before the child reads the ioctl.
lambda resume=None, sidecar_url=None: (
lambda resume=None, sidecar_url=None, profile=None: (
[sys.executable, "-c", winsize_script],
None,
None,
@@ -4566,7 +4566,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
# Patch PtyBridge.spawn at the web_server module's binding.
import hermes_cli.web_server as ws_mod
@@ -4581,7 +4581,7 @@ class TestPtyWebSocket:
def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch):
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None):
def fake_resolve(resume=None, sidecar_url=None, profile=None):
captured["resume"] = resume
return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None)
@@ -4601,7 +4601,7 @@ class TestPtyWebSocket:
same channel — which is how tool events reach the dashboard sidebar."""
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None):
def fake_resolve(resume=None, sidecar_url=None, profile=None):
captured["sidecar_url"] = sidecar_url
return (["/bin/sh", "-c", "printf sidecar-ok"], None, None)
@@ -0,0 +1,236 @@
"""Regression tests for the machine-dashboard multi-profile unification.
The dashboard is ONE machine-level management surface: config, env, MCP,
model, and chat-PTY endpoints accept an optional ``profile`` so the global
profile switcher can target any profile's HERMES_HOME. These tests pin:
reads/writes land in the REQUESTED profile, the dashboard's own profile
stays untouched, and the chat PTY env is scoped via HERMES_HOME.
"""
import pytest
import yaml
@pytest.fixture
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
"""Isolated default home + one named profile, each with config + .env."""
from hermes_constants import get_hermes_home
from hermes_cli import profiles
default_home = get_hermes_home()
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_beta"
for home in (default_home, worker_home):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
(worker_home / ".env").write_text("", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_beta": worker_home}
@pytest.fixture
def client(monkeypatch, isolated_profiles):
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
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def _cfg(home):
return yaml.safe_load((home / "config.yaml").read_text()) or {}
class TestProfileScopedConfig:
def test_config_put_lands_in_target_profile_only(self, client, isolated_profiles):
resp = client.put(
"/api/config",
json={"config": {"timezone": "Mars/Olympus"}, "profile": "worker_beta"},
)
assert resp.status_code == 200
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Mars/Olympus"
assert _cfg(isolated_profiles["default"]).get("timezone") != "Mars/Olympus"
def test_config_get_reads_target_profile(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"timezone: Venus/Cloud\n", encoding="utf-8"
)
resp = client.get("/api/config", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert resp.json().get("timezone") == "Venus/Cloud"
# Unscoped read sees the dashboard's own config.
resp = client.get("/api/config")
assert resp.json().get("timezone") != "Venus/Cloud"
def test_config_query_param_equivalent_to_body(self, client, isolated_profiles):
"""The SPA's fetchJSON injects ?profile= — must scope like body.profile."""
resp = client.put(
"/api/config?profile=worker_beta",
json={"config": {"timezone": "Pluto/Far"}},
)
assert resp.status_code == 200
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far"
assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far"
def test_config_raw_round_trip_scoped(self, client, isolated_profiles):
resp = client.put(
"/api/config/raw",
json={"yaml_text": "timezone: Io/Volcano\n", "profile": "worker_beta"},
)
assert resp.status_code == 200
resp = client.get("/api/config/raw", params={"profile": "worker_beta"})
assert "Io/Volcano" in resp.json()["yaml"]
resp = client.get("/api/config/raw")
assert "Io/Volcano" not in resp.json()["yaml"]
def test_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/config", params={"profile": "ghost"})
assert resp.status_code == 404
class TestProfileScopedEnv:
def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles):
resp = client.put(
"/api/env",
json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"},
)
assert resp.status_code == 200
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
assert "test-fal-123" in worker_env
default_env_path = isolated_profiles["default"] / ".env"
if default_env_path.exists():
assert "test-fal-123" not in default_env_path.read_text()
def test_env_list_reads_target_profile(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / ".env").write_text(
"FAL_KEY=worker-only-value\n", encoding="utf-8"
)
resp = client.get("/api/env", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert resp.json()["FAL_KEY"]["is_set"] is True
resp = client.get("/api/env")
assert resp.json()["FAL_KEY"]["is_set"] is False
def test_env_delete_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / ".env").write_text(
"FAL_KEY=doomed\n", encoding="utf-8"
)
resp = client.request(
"DELETE",
"/api/env",
json={"key": "FAL_KEY", "profile": "worker_beta"},
)
assert resp.status_code == 200
assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text()
class TestProfileScopedMcp:
def test_mcp_add_and_list_scoped(self, client, isolated_profiles):
resp = client.post(
"/api/mcp/servers",
json={"name": "scoped-srv", "url": "http://localhost:1234/sse",
"profile": "worker_beta"},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
assert "scoped-srv" in worker_cfg.get("mcp_servers", {})
assert "scoped-srv" not in _cfg(isolated_profiles["default"]).get("mcp_servers", {})
listing = client.get("/api/mcp/servers", params={"profile": "worker_beta"}).json()
assert any(s["name"] == "scoped-srv" for s in listing["servers"])
listing = client.get("/api/mcp/servers").json()
assert not any(s["name"] == "scoped-srv" for s in listing["servers"])
def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8"
)
resp = client.put(
"/api/mcp/servers/srv1/enabled",
json={"enabled": False, "profile": "worker_beta"},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
assert worker_cfg["mcp_servers"]["srv1"]["enabled"] is False
def test_mcp_remove_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"mcp_servers:\n srv2:\n url: http://x/sse\n", encoding="utf-8"
)
# Removing from the DASHBOARD's profile must 404 (srv2 lives in worker).
resp = client.delete("/api/mcp/servers/srv2")
assert resp.status_code == 404
resp = client.delete("/api/mcp/servers/srv2", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert "srv2" not in _cfg(isolated_profiles["worker_beta"]).get("mcp_servers", {})
class TestProfileScopedModel:
def test_model_set_main_scoped(self, client, isolated_profiles):
resp = client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "openrouter",
"model": "test/model-1",
"confirm_expensive_model": True,
"profile": "worker_beta",
},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
model_cfg = worker_cfg.get("model", {})
assert isinstance(model_cfg, dict)
assert model_cfg.get("provider") == "openrouter"
default_model = _cfg(isolated_profiles["default"]).get("model", {})
if isinstance(default_model, dict):
assert default_model.get("default") != "test/model-1"
class TestProfileScopedChatPty:
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
# Scoped chat must NOT attach to the dashboard's in-memory gateway.
assert "HERMES_TUI_GATEWAY_URL" not in env
def test_chat_argv_unscoped_keeps_legacy_env(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
argv, cwd, env = web_server._resolve_chat_argv()
assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"])
def test_chat_argv_unknown_profile_raises(self, isolated_profiles, monkeypatch):
from fastapi import HTTPException
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
with pytest.raises(HTTPException) as exc:
web_server._resolve_chat_argv(profile="ghost")
assert exc.value.status_code == 404