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
No known key found for this signature in database
17 changed files with 1044 additions and 291 deletions

View file

@ -10214,6 +10214,21 @@ def _report_dashboard_status() -> int:
return len(pids)
def _dashboard_listening(host: str, port: int) -> bool:
"""True when something is accepting TCP connections at host:port.
Any listener counts even a 401 response proves a dashboard is up.
Used by the unified profile-launch routing to decide attach-vs-start.
"""
import socket
try:
with socket.create_connection((host or "127.0.0.1", port), timeout=1.5):
return True
except OSError:
return False
def cmd_dashboard(args):
"""Start the web UI server, or (with --stop/--status) manage running ones."""
# --status: report running dashboards and exit, no deps needed.
@ -10234,6 +10249,65 @@ def cmd_dashboard(args):
remaining = _find_stale_dashboard_pids()
sys.exit(1 if remaining else 0)
# ── Unified profile launch routing ────────────────────────────────
# The dashboard is a MACHINE management surface: it can read/write any
# profile via the per-request ?profile= scoping. Running one dashboard
# per profile just fragments that (port collisions, N processes, and a
# "which dashboard am I on?" guessing game). So when a NAMED profile
# launches the dashboard (`worker dashboard` → HERMES_HOME points into
# profiles/), default to the machine dashboard:
# - already running → open the browser at ?profile=<name> and exit
# - not running → re-exec as the machine dashboard (pinned to the
# default profile so _apply_profile_override can't re-route through
# the sticky active_profile file) with the launching profile
# preselected in the UI's switcher.
# `--isolated` opts out and preserves the old per-profile behavior.
try:
from hermes_cli.profiles import get_active_profile_name
_launch_profile = get_active_profile_name()
except Exception:
_launch_profile = "default"
if (
_launch_profile not in ("default", "custom")
and not getattr(args, "isolated", False)
and not getattr(args, "open_profile", "")
):
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
if _dashboard_listening(args.host, args.port):
print(f"Machine dashboard already running on port {args.port}.")
print(f" Managing profile '{_launch_profile}': {url}")
if not args.no_open:
try:
import webbrowser
webbrowser.open(url)
except Exception:
pass
sys.exit(0)
print(
f"Routing to the machine dashboard (profile '{_launch_profile}' "
f"preselected). Use --isolated for a dedicated per-profile server."
)
reexec_argv = [
sys.executable, "-m", "hermes_cli.main",
"-p", "default",
"dashboard",
"--port", str(args.port),
"--host", args.host,
"--open-profile", _launch_profile,
]
if args.no_open:
reexec_argv.append("--no-open")
if getattr(args, "insecure", False):
reexec_argv.append("--insecure")
if getattr(args, "skip_build", False):
reexec_argv.append("--skip-build")
env = os.environ.copy()
# Drop the profile HERMES_HOME so the child binds the machine root.
env.pop("HERMES_HOME", None)
os.execvpe(sys.executable, reexec_argv, env)
# Attach gui.log early so dashboard startup/build failures are captured in
# the same logs directory as every other Hermes surface.
try:
@ -10307,6 +10381,7 @@ def cmd_dashboard(args):
port=args.port,
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
initial_profile=getattr(args, "open_profile", "") or "",
)

View file

@ -45,6 +45,26 @@ def build_dashboard_parser(
"where npm may not be available. Pre-build with: cd web && npm run build"
),
)
dashboard_parser.add_argument(
"--isolated",
action="store_true",
help=(
"When launched from a named profile (e.g. `worker dashboard`), run "
"a dedicated dashboard server scoped to that profile instead of "
"routing to the machine dashboard. Default behavior is unified: "
"profile launches attach to (or start) ONE machine-level dashboard "
"and preselect the profile in the UI's profile switcher."
),
)
dashboard_parser.add_argument(
"--open-profile",
dest="open_profile",
default="",
help=(
"Preselect this profile in the dashboard's profile switcher when "
"auto-opening the browser (appends ?profile=<name> to the URL)."
),
)
# Lifecycle flags — mutually exclusive with each other and with the
# start-a-server flags above (if both are passed, --stop / --status win
# because they exit before the server is started). The dashboard has

View file

@ -625,19 +625,23 @@ CONFIG_SCHEMA = _ordered_schema
class ConfigUpdate(BaseModel):
config: dict
profile: Optional[str] = None
class EnvVarUpdate(BaseModel):
key: str
value: str
profile: Optional[str] = None
class EnvVarDelete(BaseModel):
key: str
profile: Optional[str] = None
class EnvVarReveal(BaseModel):
key: str
profile: Optional[str] = None
class MessagingPlatformUpdate(BaseModel):
@ -716,6 +720,7 @@ class ModelAssignment(BaseModel):
# the path that actually wires a local endpoint into resolution.
base_url: str = ""
confirm_expensive_model: bool = False
profile: Optional[str] = None
def _apply_main_model_assignment(
@ -2495,8 +2500,9 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
@app.get("/api/config")
async def get_config():
config = _normalize_config_for_web(load_config())
async def get_config(profile: Optional[str] = None):
with _profile_scope(profile):
config = _normalize_config_for_web(load_config())
# Strip internal keys that the frontend shouldn't see or send back
return {k: v for k, v in config.items() if not k.startswith("_")}
@ -2522,7 +2528,7 @@ _EMPTY_MODEL_INFO: dict = {
@app.get("/api/model/info")
def get_model_info():
def get_model_info(profile: Optional[str] = None):
"""Return resolved model metadata for the currently configured model.
Calls the same context-length resolution chain the agent uses, so the
@ -2530,7 +2536,8 @@ def get_model_info():
Also returns model capabilities (vision, reasoning, tools) when available.
"""
try:
cfg = load_config()
with _profile_scope(profile):
cfg = load_config()
model_cfg = cfg.get("model", "")
# Extract model name and provider from the config
@ -2772,7 +2779,7 @@ def get_auxiliary_models():
@app.post("/api/model/set")
async def set_model_assignment(body: ModelAssignment):
async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = None):
"""Assign a model to the main slot or an auxiliary task slot.
Writes to ``~/.hermes/config.yaml`` applies to **new** sessions only.
@ -2789,8 +2796,10 @@ async def set_model_assignment(body: ModelAssignment):
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
try:
cfg = load_config()
# Expensive-model warning runs BEFORE the profile scope is entered:
# _profile_scope must never be held across an await (the RLock is
# reentrant per-thread, so a second coroutine interleaving on the
# event-loop thread could cross-restore the module globals).
if model and not body.confirm_expensive_model:
try:
from hermes_cli.model_cost_guard import expensive_model_warning
@ -2815,125 +2824,13 @@ async def set_model_assignment(body: ModelAssignment):
"confirm_message": warning.message,
}
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
model_cfg = _apply_main_model_assignment(
cfg.get("model", {}), provider, model, base_url
)
cfg["model"] = model_cfg
def _apply_assignment():
with _profile_scope(profile or body.profile):
return _apply_model_assignment_sync(
scope, provider, model, task, base_url
)
# When switching the main provider to Nous, mirror the CLI's
# post-model-selection behaviour (hermes_cli/main.py
# prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults):
# auto-route any *unconfigured* tools through the Nous Tool Gateway.
# This is purely additive — apply_nous_managed_defaults skips every
# tool where the user already has a direct key (FIRECRAWL_API_KEY,
# FAL_KEY, etc.) or an explicit backend/provider in config, so it
# never overwrites a user's own setup. GUI users thus land on the
# gateway the same way CLI users do, without a separate prompt.
gateway_tools: list[str] = []
if provider.strip().lower() == "nous":
try:
from hermes_cli.nous_subscription import apply_nous_managed_defaults
from hermes_cli.tools_config import _get_platform_tools
enabled = _get_platform_tools(
cfg, "cli", include_default_mcp_servers=False
)
changed = apply_nous_managed_defaults(
cfg,
enabled_toolsets=enabled,
force_fresh=True,
)
gateway_tools = sorted(changed)
except Exception:
# Portal lookup hiccups / non-subscriber / non-nous gating
# must never block saving the model assignment.
_log.debug("apply_nous_managed_defaults skipped", exc_info=True)
save_config(cfg)
# Surface auxiliary slots still pinned to a *different* provider than
# the new main one. Switching the main model does NOT touch aux pins
# (they're independent, sticky per-task overrides — see
# auxiliary_client._resolve_auto). A user who switches main away from
# a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s
# on every background aux call until they reset those pins. We never
# auto-clear them — pinning aux to a cheaper/different model is a
# legitimate config — but we tell the caller so the UI can offer a
# "reset to main" nudge instead of silently burning credits.
new_provider = provider.strip().lower()
stale_aux: list[dict] = []
aux_cfg = cfg.get("auxiliary", {})
if isinstance(aux_cfg, dict):
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot)
if not isinstance(slot_cfg, dict):
continue
slot_provider = str(slot_cfg.get("provider", "") or "").strip()
if (
slot_provider
and slot_provider.lower() not in {"auto", ""}
and slot_provider.lower() != new_provider
):
stale_aux.append({
"task": slot,
"provider": slot_provider,
"model": str(slot_cfg.get("model", "") or ""),
})
return {
"ok": True,
"scope": "main",
"provider": provider,
"model": model,
"base_url": model_cfg.get("base_url", ""),
"gateway_tools": gateway_tools,
"stale_aux": stale_aux,
}
# scope == "auxiliary"
aux = cfg.get("auxiliary")
if not isinstance(aux, dict):
aux = {}
if task == "__reset__":
# Reset every slot to provider="auto", model="" — keeps other fields intact.
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = "auto"
slot_cfg["model"] = ""
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {"ok": True, "scope": "auxiliary", "reset": True}
if not provider:
raise HTTPException(status_code=400, detail="provider required for auxiliary")
targets = [task] if task else list(_AUX_TASK_SLOTS)
for slot in targets:
if slot not in _AUX_TASK_SLOTS:
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = provider
slot_cfg["model"] = model
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {
"ok": True,
"scope": "auxiliary",
"tasks": targets,
"provider": provider,
"model": model,
}
return await asyncio.to_thread(_apply_assignment)
except HTTPException:
raise
except Exception:
@ -2941,6 +2838,138 @@ async def set_model_assignment(body: ModelAssignment):
raise HTTPException(status_code=500, detail="Failed to save model assignment")
def _apply_model_assignment_sync(
scope: str, provider: str, model: str, task: str, base_url: str
):
"""Synchronous body of POST /api/model/set.
Runs inside ``_profile_scope`` (in a worker thread) so every
load_config/save_config lands in the requested profile. Raises
HTTPException for validation errors the async wrapper re-raises them.
"""
cfg = load_config()
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
model_cfg = _apply_main_model_assignment(
cfg.get("model", {}), provider, model, base_url
)
cfg["model"] = model_cfg
# When switching the main provider to Nous, mirror the CLI's
# post-model-selection behaviour (hermes_cli/main.py
# prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults):
# auto-route any *unconfigured* tools through the Nous Tool Gateway.
# This is purely additive — apply_nous_managed_defaults skips every
# tool where the user already has a direct key (FIRECRAWL_API_KEY,
# FAL_KEY, etc.) or an explicit backend/provider in config, so it
# never overwrites a user's own setup. GUI users thus land on the
# gateway the same way CLI users do, without a separate prompt.
gateway_tools: list[str] = []
if provider.strip().lower() == "nous":
try:
from hermes_cli.nous_subscription import apply_nous_managed_defaults
from hermes_cli.tools_config import _get_platform_tools
enabled = _get_platform_tools(
cfg, "cli", include_default_mcp_servers=False
)
changed = apply_nous_managed_defaults(
cfg,
enabled_toolsets=enabled,
force_fresh=True,
)
gateway_tools = sorted(changed)
except Exception:
# Portal lookup hiccups / non-subscriber / non-nous gating
# must never block saving the model assignment.
_log.debug("apply_nous_managed_defaults skipped", exc_info=True)
save_config(cfg)
# Surface auxiliary slots still pinned to a *different* provider than
# the new main one. Switching the main model does NOT touch aux pins
# (they're independent, sticky per-task overrides — see
# auxiliary_client._resolve_auto). A user who switches main away from
# a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s
# on every background aux call until they reset those pins. We never
# auto-clear them — pinning aux to a cheaper/different model is a
# legitimate config — but we tell the caller so the UI can offer a
# "reset to main" nudge instead of silently burning credits.
new_provider = provider.strip().lower()
stale_aux: list[dict] = []
aux_cfg = cfg.get("auxiliary", {})
if isinstance(aux_cfg, dict):
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot)
if not isinstance(slot_cfg, dict):
continue
slot_provider = str(slot_cfg.get("provider", "") or "").strip()
if (
slot_provider
and slot_provider.lower() not in {"auto", ""}
and slot_provider.lower() != new_provider
):
stale_aux.append({
"task": slot,
"provider": slot_provider,
"model": str(slot_cfg.get("model", "") or ""),
})
return {
"ok": True,
"scope": "main",
"provider": provider,
"model": model,
"base_url": model_cfg.get("base_url", ""),
"gateway_tools": gateway_tools,
"stale_aux": stale_aux,
}
# scope == "auxiliary"
aux = cfg.get("auxiliary")
if not isinstance(aux, dict):
aux = {}
if task == "__reset__":
# Reset every slot to provider="auto", model="" — keeps other fields intact.
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = "auto"
slot_cfg["model"] = ""
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {"ok": True, "scope": "auxiliary", "reset": True}
if not provider:
raise HTTPException(status_code=400, detail="provider required for auxiliary")
targets = [task] if task else list(_AUX_TASK_SLOTS)
for slot in targets:
if slot not in _AUX_TASK_SLOTS:
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
slot_cfg = aux.get(slot)
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = provider
slot_cfg["model"] = model
aux[slot] = slot_cfg
cfg["auxiliary"] = aux
save_config(cfg)
return {
"ok": True,
"scope": "auxiliary",
"tasks": targets,
"provider": provider,
"model": model,
}
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
@ -2996,18 +3025,22 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
@app.put("/api/config")
async def update_config(body: ConfigUpdate):
async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
try:
save_config(_denormalize_config_from_web(body.config))
with _profile_scope(profile or body.profile):
save_config(_denormalize_config_from_web(body.config))
return {"ok": True}
except HTTPException:
raise
except Exception:
_log.exception("PUT /api/config failed")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/env")
async def get_env_vars():
env_on_disk = load_env()
async def get_env_vars(profile: Optional[str] = None):
with _profile_scope(profile):
env_on_disk = load_env()
channel_keys = _channel_managed_env_keys()
result = {}
for var_name, info in OPTIONAL_ENV_VARS.items():
@ -3030,9 +3063,10 @@ async def get_env_vars():
@app.put("/api/env")
async def set_env_var(body: EnvVarUpdate):
async def set_env_var(body: EnvVarUpdate, profile: Optional[str] = None):
try:
save_env_value(body.key, body.value)
with _profile_scope(profile or body.profile):
save_env_value(body.key, body.value)
return {"ok": True, "key": body.key}
except ValueError as exc:
# save_env_value raises ValueError for invalid names and for keys
@ -3143,9 +3177,10 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
@app.delete("/api/env")
async def remove_env_var(body: EnvVarDelete):
async def remove_env_var(body: EnvVarDelete, profile: Optional[str] = None):
try:
removed = remove_env_value(body.key)
with _profile_scope(profile or body.profile):
removed = remove_env_value(body.key)
if not removed:
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
return {"ok": True, "key": body.key}
@ -3162,7 +3197,9 @@ async def remove_env_var(body: EnvVarDelete):
@app.post("/api/env/reveal")
async def reveal_env_var(body: EnvVarReveal, request: Request):
async def reveal_env_var(
body: EnvVarReveal, request: Request, profile: Optional[str] = None
):
"""Return the real (unredacted) value of a single env var.
Protected by:
@ -3182,7 +3219,8 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
_reveal_timestamps.append(now)
# --- Reveal ---
env_on_disk = load_env()
with _profile_scope(profile or body.profile):
env_on_disk = load_env()
value = env_on_disk.get(body.key)
if value is None:
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
@ -6387,6 +6425,7 @@ class MCPServerCreate(BaseModel):
env: Dict[str, str] = {}
# auth: "oauth" | "header" | None
auth: Optional[str] = None
profile: Optional[str] = None
def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
@ -6417,10 +6456,11 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
@app.get("/api/mcp/servers")
async def list_mcp_servers():
async def list_mcp_servers(profile: Optional[str] = None):
from hermes_cli.mcp_config import _get_mcp_servers
servers = _get_mcp_servers()
with _profile_scope(profile):
servers = _get_mcp_servers()
return {
"servers": [
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
@ -6429,13 +6469,15 @@ async def list_mcp_servers():
@app.post("/api/mcp/servers")
async def add_mcp_server(body: MCPServerCreate):
async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
from hermes_cli.mcp_config import _get_mcp_servers, _save_mcp_server
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Server name is required")
if name in _get_mcp_servers():
with _profile_scope(profile or body.profile):
existing = _get_mcp_servers()
if name in existing:
raise HTTPException(status_code=409, detail=f"Server '{name}' already exists")
if not body.url and not body.command:
raise HTTPException(
@ -6456,7 +6498,10 @@ async def add_mcp_server(body: MCPServerCreate):
server_config["auth"] = body.auth
try:
_save_mcp_server(name, server_config)
with _profile_scope(profile or body.profile):
_save_mcp_server(name, server_config)
except HTTPException:
raise
except Exception as exc:
_log.exception("POST /api/mcp/servers failed")
raise HTTPException(status_code=400, detail=str(exc)) from exc
@ -6465,20 +6510,23 @@ async def add_mcp_server(body: MCPServerCreate):
@app.delete("/api/mcp/servers/{name}")
async def remove_mcp_server(name: str):
async def remove_mcp_server(name: str, profile: Optional[str] = None):
from hermes_cli.mcp_config import _remove_mcp_server
if not _remove_mcp_server(name):
with _profile_scope(profile):
removed = _remove_mcp_server(name)
if not removed:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
return {"ok": True}
@app.post("/api/mcp/servers/{name}/test")
async def test_mcp_server(name: str):
async def test_mcp_server(name: str, profile: Optional[str] = None):
"""Connect to the server, list its tools, disconnect. Returns tool list."""
from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server
servers = _get_mcp_servers()
with _profile_scope(profile):
servers = _get_mcp_servers()
if name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
@ -6500,34 +6548,40 @@ async def test_mcp_server(name: str):
class MCPEnabledToggle(BaseModel):
enabled: bool
profile: Optional[str] = None
@app.put("/api/mcp/servers/{name}/enabled")
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
async def set_mcp_server_enabled(
name: str, body: MCPEnabledToggle, profile: Optional[str] = None
):
"""Enable or disable an MCP server (takes effect on next session/gateway).
Toggles the ``enabled`` key on the server's config.yaml entry — the same
flag the agent reads at startup. Disabled servers stay in config so they
can be re-enabled without re-entering their settings.
"""
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
with _profile_scope(profile or body.profile):
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
@app.get("/api/mcp/catalog")
async def list_mcp_catalog():
async def list_mcp_catalog(profile: Optional[str] = None):
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
Each entry reports whether it's already installed and enabled so the UI
can show install / enabled state inline. This is the same catalog
`hermes mcp catalog` / `hermes mcp install` read.
`hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes
the installed/enabled annotations (the catalog itself is repo-shipped
and identical for every profile).
"""
try:
from hermes_cli import mcp_catalog
@ -6537,7 +6591,13 @@ async def list_mcp_catalog():
entries = []
try:
for entry in mcp_catalog.list_catalog():
with _profile_scope(profile):
catalog_entries = list(mcp_catalog.list_catalog())
installed_state = {
e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name))
for e in catalog_entries
}
for entry in catalog_entries:
auth = entry.auth
entries.append({
"name": entry.name,
@ -6551,8 +6611,8 @@ async def list_mcp_catalog():
for e in getattr(auth, "env", []) or []
],
"needs_install": entry.install is not None,
"installed": mcp_catalog.is_installed(entry.name),
"enabled": mcp_catalog.is_enabled(entry.name),
"installed": installed_state.get(entry.name, (False, False))[0],
"enabled": installed_state.get(entry.name, (False, False))[1],
})
except Exception:
_log.exception("list_mcp_catalog failed")
@ -6574,10 +6634,11 @@ class MCPCatalogInstall(BaseModel):
# env: KEY=VALUE map for catalog entries that declare required env vars.
env: Dict[str, str] = {}
enable: bool = True
profile: Optional[str] = None
@app.post("/api/mcp/catalog/install")
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None):
"""Install a catalog MCP into config.yaml.
For HTTP/stdio entries with required env vars, those are written to .env
@ -6594,23 +6655,42 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall):
# Persist any supplied env vars first (catalog entries declare which names
# they need; we only write the ones the user provided).
effective_profile = profile or body.profile
if body.env:
for k, v in body.env.items():
if v:
save_env_value(k, v)
with _profile_scope(effective_profile):
for k, v in body.env.items():
if v:
save_env_value(k, v)
# Git-bootstrap entries can take a while to clone — run via the background
# action path so the request returns immediately and the UI can tail logs.
# The -p subprocess rebinds HERMES_HOME-derived paths in the child.
if entry.install is not None:
try:
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
proc = _spawn_hermes_action(
_profile_cli_args(effective_profile) + ["mcp", "install", name],
"mcp-install",
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
return {"ok": True, "name": name, "background": True, "action": "mcp-install"}
# No git step — install synchronously via the catalog API.
# No git step — install synchronously via the catalog API. install_entry
# routes through load_config/save_config + save_env_value, all call-time
# resolvers, so the context override scopes it. Wrap the to_thread body
# in the scope INSIDE the thread (contextvars don't propagate into
# to_thread the other way around — asyncio.to_thread copies context, so
# setting it here works; keep it explicit for clarity).
def _install_scoped():
with _profile_scope(effective_profile):
mcp_catalog.install_entry(entry, enable=body.enable)
try:
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
await asyncio.to_thread(_install_scoped)
except HTTPException:
raise
except Exception as exc:
_log.exception("install_mcp_catalog_entry failed")
raise HTTPException(status_code=400, detail=str(exc))
@ -7394,13 +7474,13 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
@app.post("/api/skills/hub/install")
async def install_skill_hub(body: SkillInstallRequest):
async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None):
identifier = (body.identifier or "").strip()
if not identifier:
raise HTTPException(status_code=400, detail="identifier is required")
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile) + ["skills", "install", identifier],
_profile_cli_args(profile or body.profile) + ["skills", "install", identifier],
"skills-install",
)
except HTTPException:
@ -7417,13 +7497,13 @@ class SkillUninstallRequest(BaseModel):
@app.post("/api/skills/hub/uninstall")
async def uninstall_skill_hub(body: SkillUninstallRequest):
async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None):
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile) + ["skills", "uninstall", name, "--yes"],
_profile_cli_args(profile or body.profile) + ["skills", "uninstall", name, "--yes"],
"skills-uninstall",
)
except HTTPException:
@ -7439,11 +7519,13 @@ class SkillsUpdateRequest(BaseModel):
@app.post("/api/skills/hub/update")
async def update_skills_hub(body: Optional[SkillsUpdateRequest] = None):
async def update_skills_hub(
body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None
):
try:
profile = body.profile if body else None
effective = profile or (body.profile if body else None)
proc = _spawn_hermes_action(
_profile_cli_args(profile) + ["skills", "update"], "skills-update"
_profile_cli_args(effective) + ["skills", "update"], "skills-update"
)
except HTTPException:
raise
@ -8461,9 +8543,9 @@ async def get_skills(profile: Optional[str] = None):
@app.put("/api/skills/toggle")
async def toggle_skill(body: SkillToggle):
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
with _profile_scope(body.profile):
with _profile_scope(profile or body.profile):
config = load_config()
disabled = get_disabled_skills(config)
if body.enabled:
@ -8516,7 +8598,7 @@ class ToolsetToggle(BaseModel):
@app.put("/api/tools/toolsets/{name}")
async def toggle_toolset(name: str, body: ToolsetToggle):
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
"""Enable/disable a configurable toolset for the desktop (cli) platform.
Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools``
@ -8534,7 +8616,7 @@ async def toggle_toolset(name: str, body: ToolsetToggle):
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile):
with _profile_scope(profile or body.profile):
config = load_config()
enabled = set(
_get_platform_tools(config, "cli", include_default_mcp_servers=False)
@ -8616,7 +8698,9 @@ class ToolsetProviderSelect(BaseModel):
@app.put("/api/tools/toolsets/{name}/provider")
async def select_toolset_provider(name: str, body: ToolsetProviderSelect):
async def select_toolset_provider(
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None
):
"""Persist a provider selection for a toolset (no key prompting).
Delegates to ``apply_provider_selection`` the shared, non-interactive
@ -8634,7 +8718,7 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect):
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile):
with _profile_scope(profile or body.profile):
config = load_config()
try:
apply_provider_selection(name, body.provider, config)
@ -8650,7 +8734,7 @@ class ToolsetEnvUpdate(BaseModel):
@app.put("/api/tools/toolsets/{name}/env")
async def save_toolset_env(name: str, body: ToolsetEnvUpdate):
async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None):
"""Persist API keys for a toolset's provider env vars.
Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value``
@ -8672,7 +8756,7 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate):
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile):
with _profile_scope(profile or body.profile):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
allowed: set[str] = set()
@ -8753,23 +8837,26 @@ async def run_toolset_post_setup(name: str, body: ToolsetPostSetup):
class RawConfigUpdate(BaseModel):
yaml_text: str
profile: Optional[str] = None
@app.get("/api/config/raw")
async def get_config_raw():
path = get_config_path()
async def get_config_raw(profile: Optional[str] = None):
with _profile_scope(profile):
path = get_config_path()
if not path.exists():
return {"yaml": ""}
return {"yaml": path.read_text(encoding="utf-8")}
@app.put("/api/config/raw")
async def update_config_raw(body: RawConfigUpdate):
async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None):
try:
parsed = yaml.safe_load(body.yaml_text)
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="YAML must be a mapping")
save_config(parsed)
with _profile_scope(profile or body.profile):
save_config(parsed)
return {"ok": True}
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
@ -9217,6 +9304,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
def _resolve_chat_argv(
resume: Optional[str] = None,
sidecar_url: Optional[str] = None,
profile: Optional[str] = None,
) -> tuple[list[str], Optional[str], Optional[dict]]:
"""Resolve the argv + cwd + env for the chat PTY.
@ -9236,9 +9324,24 @@ def _resolve_chat_argv(
`sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so
the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the
dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`).
`profile` (when set) scopes the ENTIRE chat to that profile by pointing
``HERMES_HOME`` at the profile dir in the child env. Every spawned
process (the TUI and the ``tui_gateway.entry`` it launches) resolves
``get_hermes_home()`` from that env var at its own import, so the child
binds the profile's config, skills, memory, and state.db from the start
the same propagation ``hermes -p <name>`` performs. The in-process
``HERMES_TUI_GATEWAY_URL`` attach is SKIPPED for scoped chats: the
dashboard's in-memory gateway runs under the dashboard's own profile,
so a profile-scoped chat must spawn its own gateway subprocess.
"""
from hermes_cli.main import PROJECT_ROOT, _make_tui_argv
profile_dir: Optional[Path] = None
requested = (profile or "").strip()
if requested and requested.lower() != "current":
profile_dir = _resolve_profile_dir(requested)
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
env = os.environ.copy()
try:
@ -9256,6 +9359,9 @@ def _resolve_chat_argv(
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
env.setdefault("HERMES_TUI_INLINE", "1")
if profile_dir is not None:
env["HERMES_HOME"] = str(profile_dir)
if resume:
latest_resume, _latest_path = _session_latest_descendant(resume)
if latest_resume:
@ -9265,8 +9371,13 @@ def _resolve_chat_argv(
if sidecar_url:
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
if gateway_ws_url := _build_gateway_ws_url():
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
# Profile-scoped chats must NOT attach to the dashboard's in-memory
# gateway — it runs under the dashboard's own profile. Without the
# attach URL, gatewayClient spawns its own `tui_gateway.entry`, which
# inherits the profile HERMES_HOME set above.
if profile_dir is None:
if gateway_ws_url := _build_gateway_ws_url():
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
return list(argv), str(cwd) if cwd else None, env
@ -9429,11 +9540,19 @@ async def pty_ws(ws: WebSocket) -> None:
# --- spawn PTY ------------------------------------------------------
resume = ws.query_params.get("resume") or None
profile = ws.query_params.get("profile") or None
channel = _channel_or_close_code(ws)
sidecar_url = _build_sidecar_url(channel) if channel else None
try:
argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url)
argv, cwd, env = _resolve_chat_argv(
resume=resume, sidecar_url=sidecar_url, profile=profile
)
except HTTPException as exc:
# Unknown/invalid profile from _resolve_profile_dir.
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc.detail}\x1b[0m\r\n")
await ws.close(code=1011)
return
except SystemExit as exc:
# _make_tui_argv calls sys.exit(1) when node/npm is missing.
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n")
@ -10711,8 +10830,15 @@ def start_server(
port: int = 9119,
open_browser: bool = True,
allow_public: bool = False,
initial_profile: str = "",
):
"""Start the web UI server."""
"""Start the web UI server.
``initial_profile`` (when set) is appended to the auto-opened browser
URL as ``?profile=<name>`` so the SPA's profile switcher preselects it
used when a profile alias (``<profile> dashboard``) routes to the
machine dashboard.
"""
import uvicorn
# Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token
@ -10803,10 +10929,15 @@ def start_server(
)
if _has_display:
_open_url = f"http://{host}:{port}"
if initial_profile:
from urllib.parse import quote
_open_url += f"/?profile={quote(initial_profile)}"
def _open():
try:
time.sleep(1.0)
webbrowser.open(f"http://{host}:{port}")
webbrowser.open(_open_url)
except Exception:
pass

View file

@ -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 == []

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)

View file

@ -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

View file

@ -64,6 +64,9 @@ import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { AuthWidget } from "@/components/AuthWidget";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { ProfileProvider } from "@/contexts/ProfileProvider";
import { ProfileSwitcher } from "@/components/ProfileSwitcher";
import { ProfileScopeBanner } from "@/components/ProfileScopeBanner";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
import ConfigPage from "@/pages/ConfigPage";
@ -474,6 +477,7 @@ export default function App() {
}, []);
return (
<ProfileProvider>
<div
data-layout-variant={layoutVariant}
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
@ -528,6 +532,7 @@ export default function App() {
)}
<PluginSlot name="header-banner" />
<ProfileScopeBanner />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
<div className="flex min-h-0 min-w-0 flex-1">
@ -602,6 +607,8 @@ export default function App() {
</Button>
</div>
<ProfileSwitcher collapsed={isDesktopCollapsed} />
<nav
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
aria-label={t.app.navigation}
@ -775,6 +782,7 @@ export default function App() {
<PluginSlot name="overlay" />
</div>
</ProfileProvider>
);
}

View file

@ -0,0 +1,27 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
/**
* App-wide amber banner shown while the global switcher targets a profile
* OTHER than the dashboard's own every management write (config, keys,
* skills, MCPs, model) and new Chat sessions land in that profile.
*/
export function ProfileScopeBanner() {
const { profile, currentProfile } = useProfileScope();
const { t } = useI18n();
if (!profile || profile === currentProfile) return null;
return (
<div className="flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.app.managingProfileBanner ??
"Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile."
).replace("{name}", profile)}
</span>
</div>
);
}

View file

@ -0,0 +1,67 @@
import { Users } from "lucide-react";
import { useProfileScope } from "@/contexts/useProfileScope";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* The machine dashboard's single write-target selector.
*
* Rendered in the sidebar above the nav. Every management page (Config,
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
* fetchJSON ?profile= injection. Hidden when only one profile exists.
*/
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
const { t } = useI18n();
if (profiles.length < 2) return null;
const managed = profile || currentProfile || "default";
const isOther = !!profile && profile !== currentProfile;
return (
<div
className={cn(
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
collapsed && "lg:justify-center lg:px-0",
)}
title={t.app.managingProfile ?? "Managing profile"}
>
<Users
className={cn(
"h-3.5 w-3.5 shrink-0",
isOther ? "text-amber-300" : "text-text-tertiary",
)}
/>
<select
aria-label={t.app.managingProfile ?? "Managing profile"}
className={cn(
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
isOther
? "border-amber-500/50 text-amber-300"
: "border-border text-text-secondary",
collapsed && "lg:hidden",
)}
value={profile}
onChange={(e) => setProfile(e.target.value)}
>
<option value="">
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
{profiles
.filter((name) => name !== currentProfile)
.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{collapsed && (
<span className="sr-only">{managed}</span>
)}
</div>
);
}

View file

@ -0,0 +1,72 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useSearchParams } from "react-router-dom";
import { api, setManagementProfile } from "@/lib/api";
import { ProfileContext } from "@/contexts/profile-context";
/**
* Machine-level management-profile scope.
*
* One switcher (rendered in the sidebar) decides which profile every
* management page reads/writes. The selection lives in the URL
* (`?profile=<name>`) so it survives refresh and deep-links, and is mirrored
* into the api module so `fetchJSON` transparently appends it to the
* profile-scoped endpoint families. "" = the dashboard's own profile.
*
* This exists because "Set as active" on the Profiles page only flips the
* sticky active_profile file (future CLI/gateway runs) it cannot retarget
* the running dashboard. The switcher is the dashboard's own, visible,
* write-target selector.
*/
export function ProfileProvider({ children }: { children: ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
const [profiles, setProfiles] = useState<string[]>([]);
const [currentProfile, setCurrentProfile] = useState("default");
const profile = searchParams.get("profile") ?? "";
// Mirror into the api module synchronously on every render where it
// changed, so fetches fired by child effects in the same commit see it.
setManagementProfile(profile);
useEffect(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles.map((p) => p.name)))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => {});
}, []);
const setProfile = useCallback(
(name: string) => {
setManagementProfile(name);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const value = useMemo(
() => ({ profile, currentProfile, profiles, setProfile }),
[profile, currentProfile, profiles, setProfile],
);
return (
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
);
}

View file

@ -0,0 +1,19 @@
import { createContext } from "react";
export interface ProfileContextValue {
/** Profile every management surface reads/writes ("" = the dashboard
* process's own profile). */
profile: string;
/** The profile the dashboard process itself runs under. */
currentProfile: string;
/** Known profile names (includes "default"). */
profiles: string[];
setProfile: (name: string) => void;
}
export const ProfileContext = createContext<ProfileContextValue>({
profile: "",
currentProfile: "default",
profiles: [],
setProfile: () => {},
});

View file

@ -0,0 +1,6 @@
import { useContext } from "react";
import { ProfileContext } from "@/contexts/profile-context";
export function useProfileScope() {
return useContext(ProfileContext);
}

View file

@ -93,6 +93,10 @@ export const en: Translations = {
statusOverview: "Status overview",
system: "System",
webUi: "Web UI",
managingProfile: "Managing profile",
currentProfileOption: "this dashboard ({name})",
managingProfileBanner:
"Managing profile \u201c{name}\u201d \u2014 config, keys, skills, MCPs, model, and new chats apply to that profile.",
},
status: {

View file

@ -110,6 +110,10 @@ export interface Translations {
statusOverview: string;
system: string;
webUi: string;
/** Optional — fall back to English literals until translated. */
managingProfile?: string;
currentProfileOption?: string;
managingProfileBanner?: string;
};
// ── Status page ──

View file

@ -41,11 +41,52 @@ function setSessionHeader(headers: Headers, token: string): void {
}
}
// ── Global management-profile scope ──────────────────────────────────
// The dashboard is a machine-level management surface: one header switcher
// (ProfileProvider in App.tsx) decides which profile the management pages
// read/write, and fetchJSON transparently appends ?profile=<name> to the
// profile-scoped endpoint families below. "" = the dashboard process's own
// profile (legacy behavior). Calls that already carry an explicit profile
// (e.g. ProfileBuilder writes) are left untouched — explicit beats global.
let _managementProfile = "";
export function setManagementProfile(name: string): void {
_managementProfile = (name || "").trim();
}
export function getManagementProfile(): string {
return _managementProfile;
}
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
// channels, cron (which has its own per-job profile params), profiles
// themselves — is machine-global or self-scoped and must NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/skills",
"/api/tools/toolsets",
"/api/config",
"/api/env",
"/api/mcp",
"/api/model/info",
"/api/model/set",
];
function withManagementProfile(url: string): string {
if (!_managementProfile) return url;
if (url.includes("profile=")) return url; // explicit param wins
const path = url.split("?")[0];
if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url;
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`;
}
export async function fetchJSON<T>(
url: string,
init?: RequestInit,
options?: FetchJSONOptions,
): Promise<T> {
url = withManagementProfile(url);
// Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;

View file

@ -37,11 +37,13 @@ import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
function buildWsUrl(
authParam: [string, string],
resume: string | null,
channel: string,
profile: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
// ``authParam`` is ``["token", <session>]`` in loopback mode and
@ -49,6 +51,10 @@ function buildWsUrl(
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
if (resume) qs.set("resume", resume);
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
// selected profile, so the conversation runs with that profile's model,
// skills, memory, and sessions (see web_server._resolve_chat_argv).
if (profile) qs.set("profile", profile);
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
}
@ -173,7 +179,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// treat the current resume target as part of the PTY identity and rebuild the
// terminal session when it changes.
const resumeParam = searchParams.get("resume");
const channel = useMemo(() => generateChannelId(), [resumeParam]);
// Profile-scoped chat: spawn the PTY under the globally selected
// management profile. Changing it remounts the terminal (key below /
// effect dep) so the user explicitly starts a fresh scoped session.
const { profile: scopedProfile } = useProfileScope();
const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]);
useEffect(() => {
if (!resumeParam) return;
@ -576,7 +586,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
void (async () => {
const authParam = await buildWsAuthParam();
if (unmounting) return;
const url = buildWsUrl(authParam, resumeParam, channel);
const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
@ -714,7 +724,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
copyResetRef.current = null;
}
};
}, [channel, resumeParam]);
}, [channel, resumeParam, scopedProfile]);
// When the user returns to the chat tab (isActive: false → true), the
// terminal host just transitioned from display:none to display:flex.

View file

@ -25,7 +25,6 @@ import {
AlertTriangle,
Sparkles,
Loader2,
Users,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
@ -36,9 +35,8 @@ import type {
SkillHubInstalledEntry,
SkillHubPreview,
SkillHubScan,
ProfileInfo,
} from "@/lib/api";
import { useSearchParams } from "react-router-dom";
import { useProfileScope } from "@/contexts/useProfileScope";
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
@ -137,51 +135,15 @@ export default function SkillsPage() {
const { setAfterTitle, setEnd } = usePageHeader();
// ── Profile scoping ──
// The dashboard process runs under ONE profile, but skills/toolsets are
// per-profile state. Without an explicit selector, users who "activated"
// a profile on the Profiles page (which only affects FUTURE CLI/gateway
// runs) toggled skills here and silently wrote into the dashboard's own
// profile. The selector makes the write target explicit and deep-linkable
// via /skills?profile=<name>.
const [searchParams, setSearchParams] = useSearchParams();
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [currentProfile, setCurrentProfile] = useState<string>("");
const urlProfile = searchParams.get("profile") ?? "";
// "" = the dashboard's own profile (legacy behavior).
const selectedProfile = urlProfile;
const setSelectedProfile = useCallback(
(name: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
// The profile actually being managed, for display purposes.
const managedProfile = selectedProfile || currentProfile || "default";
const managingOtherProfile =
!!selectedProfile && selectedProfile !== currentProfile;
useEffect(() => {
// Profile list + the dashboard's own profile, for the selector. Failure
// leaves the selector hidden — the page still works profile-unscoped.
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => setCurrentProfile("default"));
}, []);
// The write target comes from the GLOBAL profile switcher (sidebar) via
// ProfileContext — one selector for the whole dashboard, deep-linkable
// as ?profile=<name>. This page just consumes it: the fetchJSON layer
// appends the param automatically; we still pass it explicitly where the
// call signature supports it (clearer, and robust if a caller bypasses
// the auto-injection).
const {
profile: selectedProfile,
} = useProfileScope();
useEffect(() => {
// Promise-chain shape: setState fires only inside async callbacks so the
@ -298,33 +260,6 @@ export default function SkillsPage() {
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
{profiles.length > 1 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
<select
aria-label={t.skills.profileSelector ?? "Profile"}
className="h-6 rounded-none border border-border bg-background px-1 text-xs text-foreground"
value={selectedProfile}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setSelectedProfile(e.target.value)
}
>
<option value="">
{(t.skills.currentProfile ?? "current ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
{profiles
.filter((p) => p.name !== currentProfile)
.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
</span>
)}
</span>,
);
setEnd(
@ -361,10 +296,6 @@ export default function SkillsPage() {
setEnd,
skills.length,
t,
profiles,
selectedProfile,
currentProfile,
setSelectedProfile,
]);
const filteredToolsets = useMemo(() => {
@ -391,18 +322,6 @@ export default function SkillsPage() {
<PluginSlot name="skills:top" />
<Toast toast={toast} />
{managingOtherProfile && (
<div className="flex items-center gap-2 border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.skills.managingProfile ??
"Managing profile “{name}” — toggles apply to that profile, not this dashboards."
).replace("{name}", managedProfile)}
</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0">