From 875aa8f162aa40f07b19b2ca229720da70193d41 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:29:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20unify=20multi-profile=20mana?= =?UTF-8?q?gement=20=E2=80=94=20one=20machine=20dashboard,=20global=20prof?= =?UTF-8?q?ile=20switcher=20(#44007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 ' - 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: - ' 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. * docs(dashboard): document machine-level multi-profile management - web-dashboard.md: 'Managing multiple profiles' section (switcher, URL deep-links, unified launch, --isolated, scoped Chat, what stays per-profile) + --isolated in the options table - profiles.md: 'From the dashboard' subsection + set-as-active vs switcher clarification - cli-commands.md: --isolated flag + profile-alias launch example * fix(dashboard): address profile-unification review findings Review findings (dev review on PR #44007): 1. HIGH — stale page state on profile switch: pages load data on mount and didn't consume the profile scope, so a page opened under profile A kept showing A's state while writes silently targeted the newly selected B. Fixed structurally: ProfileKeyedRoutes wraps the routed page tree and keys it by the selected profile, remounting every page (fresh state + refetch) on switch. ChatPage keeps its own remount (channel keyed on scopedProfile). 2. HIGH — /api/model/auxiliary read was unscoped while /api/model/set wrote scoped (Models page could show default's aux pins while editing worker's). Endpoint now takes profile + _profile_scope, added to PROFILE_SCOPED_PREFIXES, HTTPException re-raise so ghost profiles 404 instead of 500. Regression test asserts read/write symmetry with differing worker/default aux config. 3. MEDIUM — tools post-setup spawned unscoped from the profile-aware drawer. Now spawns 'hermes -p tools post-setup ' (same mechanism as hub installs); drawer threads its profile prop. Most hooks install machine-level artifacts where the scope is inert, but hooks reading config/env now see the drawer's HERMES_HOME. 4. LOW — ty warnings: env Optional asserts before subscript/membership, fastapi import replaced with web_server.HTTPException re-use. 298 tests green across the four affected suites; tsc -b + vite build green; aux scoping E2E-verified with real imports. * fix(dashboard): address second profile-unification review (gille) 1. BLOCKER — profile scope dropped on sidebar navigation: ProfileProvider derived the selection from the current URL, and nav links are bare paths, so clicking Config from /skills?profile=worker silently reset the write target. State is now the source of truth; an effect re-asserts ?profile= onto the new location after every navigation (URL stays a synchronized projection for deep links/refresh), and an incoming URL param (e.g. 'Manage skills & tools' links) still wins. 2. BLOCKER — /api/model/options unscoped while model/set wrote scoped: the picker context (current model/provider, custom providers, per-profile .env auth state) now loads inside _profile_scope; added to PROFILE_SCOPED_PREFIXES. Test: a worker-only current-model pin appears in the scoped payload and not the unscoped one. 3. BLOCKER — MCP test-server probe escaped the scope after the config read: the probe now re-enters _profile_scope inside the worker thread so env-placeholder expansion resolves against the selected profile's .env. Known limit (documented): the probe's dedicated MCP event-loop thread doesn't inherit the contextvar (OAuth token paths). Test asserts get_hermes_home() inside the probe == the worker profile dir. 4. BLOCKER — broad excepts swallowed unknown-profile 404s: /api/model/info degraded to 200-with-empty-model-info and /api/mcp/catalog to a silently-empty catalog. Both re-raise HTTPException; 404 regression tests added for info/options/catalog. Polish: scope banner clears the fixed mobile header (mt-14 lg:mt-0); --open-profile hidden via argparse.SUPPRESS (internal re-exec flag); attach-path test now asserts the opened ?profile= URL. (Stale-page-state + /api/model/auxiliary findings from this review were already fixed in 92bcd1568 — the review ran against e600f6951.) 35 tests in the two new suites + 274 in the adjacent ones, all green; tsc -b + vite build green; scoping E2E-verified with real imports. * docs(dashboard)+fix: self-review pass — Profiles page section, REST profile-param tip, body-beats-query precedence Docs: - web-dashboard.md: add the missing 'Profiles' subsection to Pages (cards, create/builder, manage-skills jump, set-as-active vs switcher distinction, editors); REST API section gets a profile-scoped-endpoints tip documenting ?profile= / body profile / 404 semantics / /api/pty - (profiles.md + cli-commands.md were already updated in e600f6951) Precedence fix: scoped endpoints taking BOTH a query param and a body field now resolve body.profile first. The SPA's fetchJSON injects the query param from the GLOBAL switcher; an explicit body.profile (e.g. Profile Builder flows writing into a specific new profile) is the more specific intent and must not be overridden by whatever the sidebar happens to be set to. Matches the documented 'explicit beats global' contract in api.ts. Verified: 304 tests green across the four suites; tsc -b + vite build green; docusaurus build green (only pre-existing broken-link warnings, none from this PR's pages). --- hermes_cli/main.py | 75 +++ hermes_cli/subcommands/dashboard.py | 20 + hermes_cli/web_server.py | 586 ++++++++++++------ .../test_dashboard_unified_launch.py | 130 ++++ tests/hermes_cli/test_web_server.py | 16 +- .../test_web_server_profile_unification.py | 385 ++++++++++++ web/src/App.tsx | 48 +- web/src/components/ProfileScopeBanner.tsx | 30 + web/src/components/ProfileSwitcher.tsx | 67 ++ web/src/components/ToolsetConfigDrawer.tsx | 2 +- web/src/contexts/ProfileProvider.tsx | 115 ++++ web/src/contexts/profile-context.ts | 19 + web/src/contexts/useProfileScope.ts | 6 + web/src/i18n/en.ts | 4 + web/src/i18n/types.ts | 4 + web/src/lib/api.ts | 47 +- web/src/pages/ChatPage.tsx | 16 +- web/src/pages/SkillsPage.tsx | 101 +-- website/docs/reference/cli-commands.md | 5 + .../docs/user-guide/features/web-dashboard.md | 59 ++ website/docs/user-guide/profiles.md | 14 + 21 files changed, 1429 insertions(+), 320 deletions(-) create mode 100644 tests/hermes_cli/test_dashboard_unified_launch.py create mode 100644 tests/hermes_cli/test_web_server_profile_unification.py create mode 100644 web/src/components/ProfileScopeBanner.tsx create mode 100644 web/src/components/ProfileSwitcher.tsx create mode 100644 web/src/contexts/ProfileProvider.tsx create mode 100644 web/src/contexts/profile-context.ts create mode 100644 web/src/contexts/useProfileScope.ts diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 53441055958..719a181bc25 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10220,6 +10220,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. @@ -10240,6 +10255,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= 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: @@ -10313,6 +10387,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 "", ) diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index 6bdb858513d..01ee57e2624 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -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." + ), + ) + # Internal flag set by the unified-launch re-exec (cmd_dashboard) to + # preselect the launching profile in the SPA switcher. Hidden from + # --help: users get this behavior automatically via ` dashboard`. + dashboard_parser.add_argument( + "--open-profile", + dest="open_profile", + default="", + help=argparse.SUPPRESS, + ) # 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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ef1c15bac93..1b83a95893a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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( @@ -2517,8 +2522,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("_")} @@ -2544,7 +2550,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 @@ -2552,7 +2558,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 @@ -2615,6 +2622,10 @@ def get_model_info(): "effective_context_length": effective_ctx, "capabilities": caps, } + except HTTPException: + # Unknown/invalid profile must surface as 404, not degrade into a + # 200 with empty model info (which would render as "no model set"). + raise except Exception: _log.exception("GET /api/model/info failed") return dict(_EMPTY_MODEL_INFO) @@ -2644,13 +2655,17 @@ _AUX_TASK_SLOTS: Tuple[str, ...] = ( @app.get("/api/model/options") -def get_model_options(): +def get_model_options(profile: Optional[str] = None): """Return authenticated providers + their curated model lists. REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the dashboard Models page can render the picker without a live chat session. The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog`` can share the same types. + + ``profile`` scopes the picker context (current model/provider, custom + providers from config, per-profile .env auth state) so the Models page + reads the SAME profile /api/model/set writes. """ try: from hermes_cli.inventory import build_models_payload, load_picker_context @@ -2663,15 +2678,18 @@ def get_model_options(): # come back as skeleton rows carrying `authenticated=False` + # `auth_type`/`key_env`/`warning` so the GUI can render a setup # affordance instead of hiding the provider entirely. - return build_models_payload( - load_picker_context(), - max_models=50, - include_unconfigured=True, - picker_hints=True, - canonical_order=True, - pricing=True, - capabilities=True, - ) + with _profile_scope(profile): + return build_models_payload( + load_picker_context(), + max_models=50, + include_unconfigured=True, + picker_hints=True, + canonical_order=True, + pricing=True, + capabilities=True, + ) + except HTTPException: + raise except Exception: _log.exception("GET /api/model/options failed") raise HTTPException(status_code=500, detail="Failed to list model options") @@ -2750,7 +2768,7 @@ def get_recommended_default_model(provider: str = ""): @app.get("/api/model/auxiliary") -def get_auxiliary_models(): +def get_auxiliary_models(profile: Optional[str] = None): """Return current auxiliary task assignments. Shape: @@ -2761,9 +2779,14 @@ def get_auxiliary_models(): ], "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"}, } + + ``profile`` scopes the read — without it, the Models page would show + the dashboard profile's auxiliary pins while /api/model/set wrote the + selected profile's (read/write asymmetry). """ try: - cfg = load_config() + with _profile_scope(profile): + cfg = load_config() aux_cfg = cfg.get("auxiliary", {}) if not isinstance(aux_cfg, dict): aux_cfg = {} @@ -2788,13 +2811,15 @@ def get_auxiliary_models(): main = {"provider": "", "model": str(model_cfg) if model_cfg else ""} return {"tasks": tasks, "main": main} + except HTTPException: + raise except Exception: _log.exception("GET /api/model/auxiliary failed") raise HTTPException(status_code=500, detail="Failed to read auxiliary config") @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. @@ -2811,8 +2836,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 @@ -2837,125 +2864,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(body.profile or 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: @@ -2963,6 +2878,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]: @@ -3018,18 +3065,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(body.profile or 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(): @@ -3052,9 +3103,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(body.profile or 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 @@ -3165,9 +3217,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(body.profile or 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} @@ -3184,7 +3237,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: @@ -3204,7 +3259,8 @@ async def reveal_env_var(body: EnvVarReveal, request: Request): _reveal_timestamps.append(now) # --- Reveal --- - env_on_disk = load_env() + with _profile_scope(body.profile or 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") @@ -6409,6 +6465,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]: @@ -6439,10 +6496,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()) @@ -6451,13 +6509,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(body.profile or 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( @@ -6478,7 +6538,10 @@ async def add_mcp_server(body: MCPServerCreate): server_config["auth"] = body.auth try: - _save_mcp_server(name, server_config) + with _profile_scope(body.profile or 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 @@ -6487,27 +6550,43 @@ 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") + def _probe_scoped(): + # Re-enter the scope INSIDE the worker thread so call-time + # resolution during the probe — env-placeholder expansion in + # _resolve_mcp_server_config reading the profile's .env — sees the + # selected profile, matching the config the server was saved into. + # (asyncio.to_thread copies contextvars, but entering explicitly + # keeps the lock-protected SKILLS_DIR swap balanced per-thread.) + # Known limit: the dedicated MCP event-loop thread spawned by the + # probe doesn't inherit the contextvar, so OAuth token-store paths + # resolve against the process HERMES_HOME. + with _profile_scope(profile): + return _probe_single_server(name, servers[name]) + try: # Probe blocks on a dedicated MCP event loop — run in a thread so the # FastAPI event loop is never blocked. - tools = await asyncio.to_thread(_probe_single_server, name, servers[name]) + tools = await asyncio.to_thread(_probe_scoped) except Exception as exc: return { "ok": False, @@ -6522,34 +6601,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(body.profile or 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 @@ -6559,7 +6644,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, @@ -6573,9 +6664,12 @@ 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 HTTPException: + # Unknown/invalid profile → 404, not a silently-empty catalog. + raise except Exception: _log.exception("list_mcp_catalog failed") @@ -6596,10 +6690,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 @@ -6616,23 +6711,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 = body.profile or 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)) @@ -7437,13 +7551,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(body.profile or profile) + ["skills", "install", identifier], "skills-install", ) except HTTPException: @@ -7460,13 +7574,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(body.profile or profile) + ["skills", "uninstall", name, "--yes"], "skills-uninstall", ) except HTTPException: @@ -7482,11 +7596,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 = (body.profile if body else None) or profile proc = _spawn_hermes_action( - _profile_cli_args(profile) + ["skills", "update"], "skills-update" + _profile_cli_args(effective) + ["skills", "update"], "skills-update" ) except HTTPException: raise @@ -8504,9 +8620,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(body.profile or profile): config = load_config() disabled = get_disabled_skills(config) if body.enabled: @@ -8559,7 +8675,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`` @@ -8577,7 +8693,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(body.profile or profile): config = load_config() enabled = set( _get_platform_tools(config, "cli", include_default_mcp_servers=False) @@ -8659,7 +8775,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 @@ -8677,7 +8795,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(body.profile or profile): config = load_config() try: apply_provider_selection(name, body.provider, config) @@ -8693,7 +8811,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`` — @@ -8715,7 +8833,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(body.profile or profile): config = load_config() cat = TOOL_CATEGORIES.get(name) allowed: set[str] = set() @@ -8749,10 +8867,13 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate): class ToolsetPostSetup(BaseModel): key: str + profile: Optional[str] = None @app.post("/api/tools/toolsets/{name}/post-setup") -async def run_toolset_post_setup(name: str, body: ToolsetPostSetup): +async def run_toolset_post_setup( + name: str, body: ToolsetPostSetup, profile: Optional[str] = None +): """Spawn a provider's post-setup install hook as a background action. Post-setup hooks (npm install for browser/Camofox, pip install for @@ -8762,6 +8883,12 @@ async def run_toolset_post_setup(name: str, body: ToolsetPostSetup): ``GET /api/actions/tools-post-setup/status``. The ``key`` is validated against the declared post-setup allowlist before spawning. Returns 400 for unknown toolset or post-setup key. + + ``profile`` spawns the hook as ``hermes -p tools post-setup``. + Most hooks install machine-level artifacts (repo node_modules, shared + pip packages) where the scope is inert, but hooks that read config or + write per-profile state must see the same HERMES_HOME the rest of the + drawer's writes targeted — so the scope is threaded for consistency. """ from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, @@ -8779,8 +8906,12 @@ async def run_toolset_post_setup(name: str, body: ToolsetPostSetup): try: proc = _spawn_hermes_action( - ["tools", "post-setup", body.key], "tools-post-setup" + _profile_cli_args(body.profile or profile) + + ["tools", "post-setup", body.key], + "tools-post-setup", ) + except HTTPException: + raise except Exception as exc: _log.exception("Failed to spawn tools post-setup") raise HTTPException( @@ -8796,23 +8927,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(body.profile or profile): + save_config(parsed) return {"ok": True} except yaml.YAMLError as e: raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") @@ -9260,6 +9394,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. @@ -9279,9 +9414,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 `` 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: @@ -9299,6 +9449,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: @@ -9308,8 +9461,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 @@ -9472,11 +9630,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") @@ -10754,8 +10920,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=`` so the SPA's profile switcher preselects it + — used when a profile alias (`` dashboard``) routes to the + machine dashboard. + """ import uvicorn # Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token @@ -10846,10 +11019,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 diff --git a/tests/hermes_cli/test_dashboard_unified_launch.py b/tests/hermes_cli/test_dashboard_unified_launch.py new file mode 100644 index 00000000000..232d7a4a394 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_unified_launch.py @@ -0,0 +1,130 @@ +"""Tests for the unified profile→machine dashboard launch routing. + +` 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_attach_opens_scoped_url(self, main_mod, monkeypatch): + """The attach path must open the browser at ?profile= — that + URL is the entire point of attaching (preselects the switcher).""" + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" + ) + monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True) + opened = [] + import webbrowser + monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url)) + + with pytest.raises(SystemExit) as exc: + main_mod.cmd_dashboard(_args(no_open=False)) + assert exc.value.code == 0 + assert opened == ["http://127.0.0.1:9119/?profile=worker_x"] + + 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 == [] diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 76cbd59efdc..3aeca71c7de 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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) diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py new file mode 100644 index 00000000000..d458348f128 --- /dev/null +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -0,0 +1,385 @@ +"""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_probe_runs_inside_profile_scope( + self, client, isolated_profiles, monkeypatch + ): + """The test-server probe must execute with the selected profile's + scope active so env-placeholder expansion reads the profile's .env, + matching the config the server was saved into.""" + import hermes_cli.mcp_config as mcp_config + from hermes_constants import get_hermes_home + + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "mcp_servers:\n probe-srv:\n url: http://x/sse\n", + encoding="utf-8", + ) + seen = {} + + def fake_probe(name, config, connect_timeout=30): + seen["home"] = str(get_hermes_home()) + return [("tool-a", "desc")] + + monkeypatch.setattr(mcp_config, "_probe_single_server", fake_probe) + resp = client.post( + "/api/mcp/servers/probe-srv/test", params={"profile": "worker_beta"} + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert seen["home"] == str(isolated_profiles["worker_beta"]) + + 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" + + def test_auxiliary_read_scoped_matches_write_target( + self, client, isolated_profiles + ): + """Reads and writes must scope symmetrically: an aux pin written to + the worker profile must show up ONLY in the worker-scoped read. + (Regression: /api/model/auxiliary used to read unscoped while + /api/model/set wrote scoped — the Models page displayed the + dashboard profile's pins while editing the selected profile's.)""" + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "auxiliary:\n vision:\n provider: openrouter\n" + " model: worker/vision-pin\n", + encoding="utf-8", + ) + resp = client.get("/api/model/auxiliary", params={"profile": "worker_beta"}) + assert resp.status_code == 200 + vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision") + assert vision["model"] == "worker/vision-pin" + + # Unscoped read = the dashboard's own profile, which has no pin. + resp = client.get("/api/model/auxiliary") + assert resp.status_code == 200 + vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision") + assert vision["model"] != "worker/vision-pin" + + def test_auxiliary_unknown_profile_404(self, client, isolated_profiles): + resp = client.get("/api/model/auxiliary", params={"profile": "ghost"}) + assert resp.status_code == 404 + + def test_model_options_scoped_to_profile(self, client, isolated_profiles): + """The Models picker must read the SAME profile model/set writes — + current model/provider in the payload come from the scoped config.""" + (isolated_profiles["worker_beta"] / "config.yaml").write_text( + "model:\n provider: openrouter\n default: worker/current-pin\n", + encoding="utf-8", + ) + resp = client.get("/api/model/options", params={"profile": "worker_beta"}) + assert resp.status_code == 200 + body = resp.json() + # The payload carries the current selection somewhere stable; assert + # the worker pin appears in the scoped response and not the unscoped. + assert "worker/current-pin" in resp.text + resp = client.get("/api/model/options") + assert resp.status_code == 200 + assert "worker/current-pin" not in resp.text + assert isinstance(body, dict) + + def test_model_options_unknown_profile_404(self, client, isolated_profiles): + resp = client.get("/api/model/options", params={"profile": "ghost"}) + assert resp.status_code == 404 + + def test_model_info_unknown_profile_404(self, client, isolated_profiles): + """Regression: the broad except used to convert the 404 into a 200 + with empty model info ("no model set" — silently wrong).""" + resp = client.get("/api/model/info", params={"profile": "ghost"}) + assert resp.status_code == 404 + + def test_mcp_catalog_unknown_profile_404(self, client, isolated_profiles): + resp = client.get("/api/mcp/catalog", params={"profile": "ghost"}) + assert resp.status_code == 404 + + +class TestProfileScopedPostSetup: + def test_post_setup_spawns_with_profile_flag( + self, client, isolated_profiles, monkeypatch + ): + """Post-setup runs in a -p scoped subprocess so hooks that read + config / write per-profile state see the same HERMES_HOME the rest + of the drawer's writes targeted.""" + import hermes_cli.web_server as web_server + + calls = [] + + class _FakeProc: + pid = 777 + + monkeypatch.setattr( + web_server, + "_spawn_hermes_action", + lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(), + ) + monkeypatch.setattr( + "hermes_cli.tools_config.valid_post_setup_keys", + lambda: {"agent_browser"}, + ) + resp = client.post( + "/api/tools/toolsets/browser/post-setup", + json={"key": "agent_browser", "profile": "worker_beta"}, + ) + assert resp.status_code == 200 + assert calls == [ + ["-p", "worker_beta", "tools", "post-setup", "agent_browser"] + ] + + def test_post_setup_without_profile_keeps_legacy_argv( + self, client, isolated_profiles, monkeypatch + ): + import hermes_cli.web_server as web_server + + calls = [] + + class _FakeProc: + pid = 777 + + monkeypatch.setattr( + web_server, + "_spawn_hermes_action", + lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(), + ) + monkeypatch.setattr( + "hermes_cli.tools_config.valid_post_setup_keys", + lambda: {"agent_browser"}, + ) + resp = client.post( + "/api/tools/toolsets/browser/post-setup", + json={"key": "agent_browser"}, + ) + assert resp.status_code == 200 + assert calls == [["tools", "post-setup", "agent_browser"]] + + +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 is not None + 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 is not None + assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"]) + + def test_chat_argv_unknown_profile_raises(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, + ) + # Reuse the HTTPException class web_server itself raises — avoids a + # direct fastapi import (unresolvable in the ty lint environment). + with pytest.raises(web_server.HTTPException) as exc: + web_server._resolve_chat_argv(profile="ghost") + assert exc.value.status_code == 404 diff --git a/web/src/App.tsx b/web/src/App.tsx index 52108a22cec..d3c976358d5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -64,6 +64,10 @@ 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 { useProfileScope } from "@/contexts/useProfileScope"; +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 +478,7 @@ export default function App() { }, []); return ( +
+
@@ -602,6 +608,8 @@ export default function App() {
+ +
+ ); } +/** + * Remounts the entire routed page tree when the global management profile + * changes. Pages load their data on mount; without this, a page opened + * under profile A would keep showing A's state while writes (via the + * fetchJSON ?profile= injection) silently targeted the newly selected + * profile B — the exact stale-target footgun the switcher exists to kill. + * Keying by profile resets every page's local state so it refetches under + * the new scope. The persistent ChatPage host below handles its own + * remount (channel keyed on scopedProfile). + */ +function ProfileKeyedRoutes({ children }: { children: ReactNode }) { + const { profile } = useProfileScope(); + return
{children}
; +} + function SidebarNavLink({ closeMobile, collapsed, diff --git a/web/src/components/ProfileScopeBanner.tsx b/web/src/components/ProfileScopeBanner.tsx new file mode 100644 index 00000000000..9d5adc2fdfd --- /dev/null +++ b/web/src/components/ProfileScopeBanner.tsx @@ -0,0 +1,30 @@ +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 ( + // mt-14 on mobile clears the fixed lg:hidden header (h-14, z-40) so the + // scope banner — the main safety signal for scoped writes — is never + // hidden behind it; lg:mt-0 restores desktop flow. +
+ + + {( + t.app.managingProfileBanner ?? + "Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile." + ).replace("{name}", profile)} + +
+ ); +} diff --git a/web/src/components/ProfileSwitcher.tsx b/web/src/components/ProfileSwitcher.tsx new file mode 100644 index 00000000000..827ea881f6f --- /dev/null +++ b/web/src/components/ProfileSwitcher.tsx @@ -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 ( +
+ + + {collapsed && ( + {managed} + )} +
+ ); +} diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx index 5bbcba61866..792393c9285 100644 --- a/web/src/components/ToolsetConfigDrawer.tsx +++ b/web/src/components/ToolsetConfigDrawer.tsx @@ -198,7 +198,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr setPostSetupLog([]); setPostSetupKey(provider.post_setup); try { - await api.runToolsetPostSetup(toolset.name, provider.post_setup); + await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile); // Bump the trigger so the poll effect (re)starts tailing the log. setPostSetupTrigger((n) => n + 1); } catch (e) { diff --git a/web/src/contexts/ProfileProvider.tsx b/web/src/contexts/ProfileProvider.tsx new file mode 100644 index 00000000000..0beedb49bc5 --- /dev/null +++ b/web/src/contexts/ProfileProvider.tsx @@ -0,0 +1,115 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useLocation, 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. React STATE is the source of truth; the + * URL (`?profile=`) is a synchronized projection of it so deep links + * land scoped and refresh survives. The selection is mirrored into the api + * module so `fetchJSON` transparently appends it to the profile-scoped + * endpoint families. "" = the dashboard's own profile. + * + * Why state-first instead of URL-first: sidebar nav links are bare paths + * (`/config`, `/skills`). A URL-derived scope would silently reset to the + * dashboard's own profile on every nav click — the switcher would LOOK + * global while normal navigation dropped the write target. With state as + * truth, the effect below re-asserts `?profile=` onto the new location + * after each navigation, so the scope survives nav and stays deep-linkable. + * + * 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 { pathname } = useLocation(); + const [profiles, setProfiles] = useState([]); + const [currentProfile, setCurrentProfile] = useState("default"); + + // Initial value comes from the URL (deep link / refresh / unified-launch + // preselect); afterwards state leads and the URL follows. + const [profile, setProfileState] = useState( + () => 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); + + // A profile param arriving via in-app navigation (e.g. the Profiles + // page's "Manage skills & tools" linking to /skills?profile=X) must win + // over current state — it's an explicit scope request. + const urlProfile = searchParams.get("profile"); + useEffect(() => { + if (urlProfile !== null && urlProfile !== profile) { + setManagementProfile(urlProfile); + setProfileState(urlProfile); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlProfile]); + + // Re-assert ?profile= after navigations that dropped it (bare nav links). + // Runs on every pathname/profile change; no-ops when already in sync. + useEffect(() => { + const inUrl = searchParams.get("profile") ?? ""; + if ((profile || "") === inUrl) return; + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (profile) next.set("profile", profile); + else next.delete("profile"); + return next; + }, + { replace: true }, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname, 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); + setProfileState(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 ( + {children} + ); +} diff --git a/web/src/contexts/profile-context.ts b/web/src/contexts/profile-context.ts new file mode 100644 index 00000000000..f8b2e5c9514 --- /dev/null +++ b/web/src/contexts/profile-context.ts @@ -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({ + profile: "", + currentProfile: "default", + profiles: [], + setProfile: () => {}, +}); diff --git a/web/src/contexts/useProfileScope.ts b/web/src/contexts/useProfileScope.ts new file mode 100644 index 00000000000..9bd3fefcddc --- /dev/null +++ b/web/src/contexts/useProfileScope.ts @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { ProfileContext } from "@/contexts/profile-context"; + +export function useProfileScope() { + return useContext(ProfileContext); +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 39cf80d6995..853eeb4a9c1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index cac5688bdc6..aecb863544e 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -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 ── diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a7c308353bb..a587a0f0c1a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -41,11 +41,54 @@ 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= 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", + "/api/model/auxiliary", + "/api/model/options", +]; + +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( url: string, init?: RequestInit, options?: FetchJSONOptions, ): Promise { + url = withManagementProfile(url); // Inject the session token into all /api/ requests. const headers = new Headers(init?.headers); const token = window.__HERMES_SESSION_TOKEN__; @@ -595,13 +638,13 @@ export const api = { body: JSON.stringify({ env, profile: profile || undefined }), }, ), - runToolsetPostSetup: (name: string, key: string) => + runToolsetPostSetup: (name: string, key: string, profile?: string) => fetchJSON( `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), + body: JSON.stringify({ key, profile: profile || undefined }), }, ), diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index e3503848356..34975035530 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -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", ]`` 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. diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index c3a5d324e15..7834de1cf46 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -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=. - const [searchParams, setSearchParams] = useSearchParams(); - const [profiles, setProfiles] = useState([]); - const [currentProfile, setCurrentProfile] = useState(""); - 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=. 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 && ( - - - - - )} , ); 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() { - {managingOtherProfile && ( -
- - - {( - t.skills.managingProfile ?? - "Managing profile “{name}” — toggles apply to that profile, not this dashboard’s." - ).replace("{name}", managedProfile)} - -
- )} -