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)} - -
- )} -