mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-16 09:31:37 +00:00
feat(dashboard): unify multi-profile management — one machine dashboard, global profile switcher
The dashboard becomes a machine-level management surface with one write-target selector, replacing per-profile dashboard fragmentation. Backend: - profile param (query or body) on /api/config (get/put/raw), /api/env (get/put/delete/reveal), /api/mcp/servers (list/add/remove/test/enabled), /api/mcp/catalog (list/install), /api/model/info, /api/model/set — all scoped through the existing _profile_scope() context manager - model/set restructured: expensive-model warning (await) runs before the scope; the config write runs sync inside the scope in a worker thread - MCP catalog installs + git-bootstrap entries spawn 'hermes -p <profile>' - chat PTY: ?profile= on /api/pty points the child's HERMES_HOME at the profile dir (its own gateway subprocess, config/skills/memory/state.db all profile-bound); in-process gateway attach skipped when scoped CLI launch unification: - '<profile> dashboard' routes to the machine dashboard: attach (open browser at ?profile=) when one is listening, else re-exec pinned to the default profile with --open-profile preselecting the launcher - --isolated preserves the old dedicated per-profile server behavior - start_server(initial_profile=...) appends ?profile= to the auto-open URL Frontend: - ProfileProvider + sidebar ProfileSwitcher: ONE global selector, URL- persisted (?profile=), mirrored into fetchJSON which auto-appends the param to the scoped endpoint families (explicit params win) - app-wide amber banner names the managed profile - SkillsPage's page-local selector (from the skills-scoping PR) folded into the global context — single source of truth - ChatPage threads the scope into the PTY WS URL; switching profiles remounts the terminal into a fresh scoped session Omitted profile keeps legacy behavior everywhere.
This commit is contained in:
parent
3e74f75e41
commit
f02302738d
17 changed files with 1044 additions and 291 deletions
|
|
@ -10214,6 +10214,21 @@ def _report_dashboard_status() -> int:
|
|||
return len(pids)
|
||||
|
||||
|
||||
def _dashboard_listening(host: str, port: int) -> bool:
|
||||
"""True when something is accepting TCP connections at host:port.
|
||||
|
||||
Any listener counts — even a 401 response proves a dashboard is up.
|
||||
Used by the unified profile-launch routing to decide attach-vs-start.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host or "127.0.0.1", port), timeout=1.5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_dashboard(args):
|
||||
"""Start the web UI server, or (with --stop/--status) manage running ones."""
|
||||
# --status: report running dashboards and exit, no deps needed.
|
||||
|
|
@ -10234,6 +10249,65 @@ def cmd_dashboard(args):
|
|||
remaining = _find_stale_dashboard_pids()
|
||||
sys.exit(1 if remaining else 0)
|
||||
|
||||
# ── Unified profile launch routing ────────────────────────────────
|
||||
# The dashboard is a MACHINE management surface: it can read/write any
|
||||
# profile via the per-request ?profile= scoping. Running one dashboard
|
||||
# per profile just fragments that (port collisions, N processes, and a
|
||||
# "which dashboard am I on?" guessing game). So when a NAMED profile
|
||||
# launches the dashboard (`worker dashboard` → HERMES_HOME points into
|
||||
# profiles/), default to the machine dashboard:
|
||||
# - already running → open the browser at ?profile=<name> and exit
|
||||
# - not running → re-exec as the machine dashboard (pinned to the
|
||||
# default profile so _apply_profile_override can't re-route through
|
||||
# the sticky active_profile file) with the launching profile
|
||||
# preselected in the UI's switcher.
|
||||
# `--isolated` opts out and preserves the old per-profile behavior.
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
_launch_profile = get_active_profile_name()
|
||||
except Exception:
|
||||
_launch_profile = "default"
|
||||
|
||||
if (
|
||||
_launch_profile not in ("default", "custom")
|
||||
and not getattr(args, "isolated", False)
|
||||
and not getattr(args, "open_profile", "")
|
||||
):
|
||||
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
|
||||
if _dashboard_listening(args.host, args.port):
|
||||
print(f"Machine dashboard already running on port {args.port}.")
|
||||
print(f" Managing profile '{_launch_profile}': {url}")
|
||||
if not args.no_open:
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
|
||||
print(
|
||||
f"Routing to the machine dashboard (profile '{_launch_profile}' "
|
||||
f"preselected). Use --isolated for a dedicated per-profile server."
|
||||
)
|
||||
reexec_argv = [
|
||||
sys.executable, "-m", "hermes_cli.main",
|
||||
"-p", "default",
|
||||
"dashboard",
|
||||
"--port", str(args.port),
|
||||
"--host", args.host,
|
||||
"--open-profile", _launch_profile,
|
||||
]
|
||||
if args.no_open:
|
||||
reexec_argv.append("--no-open")
|
||||
if getattr(args, "insecure", False):
|
||||
reexec_argv.append("--insecure")
|
||||
if getattr(args, "skip_build", False):
|
||||
reexec_argv.append("--skip-build")
|
||||
env = os.environ.copy()
|
||||
# Drop the profile HERMES_HOME so the child binds the machine root.
|
||||
env.pop("HERMES_HOME", None)
|
||||
os.execvpe(sys.executable, reexec_argv, env)
|
||||
|
||||
# Attach gui.log early so dashboard startup/build failures are captured in
|
||||
# the same logs directory as every other Hermes surface.
|
||||
try:
|
||||
|
|
@ -10307,6 +10381,7 @@ def cmd_dashboard(args):
|
|||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
allow_public=getattr(args, "insecure", False),
|
||||
initial_profile=getattr(args, "open_profile", "") or "",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,26 @@ def build_dashboard_parser(
|
|||
"where npm may not be available. Pre-build with: cd web && npm run build"
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--isolated",
|
||||
action="store_true",
|
||||
help=(
|
||||
"When launched from a named profile (e.g. `worker dashboard`), run "
|
||||
"a dedicated dashboard server scoped to that profile instead of "
|
||||
"routing to the machine dashboard. Default behavior is unified: "
|
||||
"profile launches attach to (or start) ONE machine-level dashboard "
|
||||
"and preselect the profile in the UI's profile switcher."
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--open-profile",
|
||||
dest="open_profile",
|
||||
default="",
|
||||
help=(
|
||||
"Preselect this profile in the dashboard's profile switcher when "
|
||||
"auto-opening the browser (appends ?profile=<name> to the URL)."
|
||||
),
|
||||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
|
|
|
|||
|
|
@ -625,19 +625,23 @@ CONFIG_SCHEMA = _ordered_schema
|
|||
|
||||
class ConfigUpdate(BaseModel):
|
||||
config: dict
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class EnvVarUpdate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class EnvVarDelete(BaseModel):
|
||||
key: str
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class EnvVarReveal(BaseModel):
|
||||
key: str
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class MessagingPlatformUpdate(BaseModel):
|
||||
|
|
@ -716,6 +720,7 @@ class ModelAssignment(BaseModel):
|
|||
# the path that actually wires a local endpoint into resolution.
|
||||
base_url: str = ""
|
||||
confirm_expensive_model: bool = False
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
def _apply_main_model_assignment(
|
||||
|
|
@ -2495,8 +2500,9 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config():
|
||||
config = _normalize_config_for_web(load_config())
|
||||
async def get_config(profile: Optional[str] = None):
|
||||
with _profile_scope(profile):
|
||||
config = _normalize_config_for_web(load_config())
|
||||
# Strip internal keys that the frontend shouldn't see or send back
|
||||
return {k: v for k, v in config.items() if not k.startswith("_")}
|
||||
|
||||
|
|
@ -2522,7 +2528,7 @@ _EMPTY_MODEL_INFO: dict = {
|
|||
|
||||
|
||||
@app.get("/api/model/info")
|
||||
def get_model_info():
|
||||
def get_model_info(profile: Optional[str] = None):
|
||||
"""Return resolved model metadata for the currently configured model.
|
||||
|
||||
Calls the same context-length resolution chain the agent uses, so the
|
||||
|
|
@ -2530,7 +2536,8 @@ def get_model_info():
|
|||
Also returns model capabilities (vision, reasoning, tools) when available.
|
||||
"""
|
||||
try:
|
||||
cfg = load_config()
|
||||
with _profile_scope(profile):
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", "")
|
||||
|
||||
# Extract model name and provider from the config
|
||||
|
|
@ -2772,7 +2779,7 @@ def get_auxiliary_models():
|
|||
|
||||
|
||||
@app.post("/api/model/set")
|
||||
async def set_model_assignment(body: ModelAssignment):
|
||||
async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = None):
|
||||
"""Assign a model to the main slot or an auxiliary task slot.
|
||||
|
||||
Writes to ``~/.hermes/config.yaml`` — applies to **new** sessions only.
|
||||
|
|
@ -2789,8 +2796,10 @@ async def set_model_assignment(body: ModelAssignment):
|
|||
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
|
||||
# Expensive-model warning runs BEFORE the profile scope is entered:
|
||||
# _profile_scope must never be held across an await (the RLock is
|
||||
# reentrant per-thread, so a second coroutine interleaving on the
|
||||
# event-loop thread could cross-restore the module globals).
|
||||
if model and not body.confirm_expensive_model:
|
||||
try:
|
||||
from hermes_cli.model_cost_guard import expensive_model_warning
|
||||
|
|
@ -2815,125 +2824,13 @@ async def set_model_assignment(body: ModelAssignment):
|
|||
"confirm_message": warning.message,
|
||||
}
|
||||
|
||||
if scope == "main":
|
||||
if not provider or not model:
|
||||
raise HTTPException(status_code=400, detail="provider and model required for main")
|
||||
model_cfg = _apply_main_model_assignment(
|
||||
cfg.get("model", {}), provider, model, base_url
|
||||
)
|
||||
cfg["model"] = model_cfg
|
||||
def _apply_assignment():
|
||||
with _profile_scope(profile or body.profile):
|
||||
return _apply_model_assignment_sync(
|
||||
scope, provider, model, task, base_url
|
||||
)
|
||||
|
||||
# When switching the main provider to Nous, mirror the CLI's
|
||||
# post-model-selection behaviour (hermes_cli/main.py
|
||||
# prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults):
|
||||
# auto-route any *unconfigured* tools through the Nous Tool Gateway.
|
||||
# This is purely additive — apply_nous_managed_defaults skips every
|
||||
# tool where the user already has a direct key (FIRECRAWL_API_KEY,
|
||||
# FAL_KEY, etc.) or an explicit backend/provider in config, so it
|
||||
# never overwrites a user's own setup. GUI users thus land on the
|
||||
# gateway the same way CLI users do, without a separate prompt.
|
||||
gateway_tools: list[str] = []
|
||||
if provider.strip().lower() == "nous":
|
||||
try:
|
||||
from hermes_cli.nous_subscription import apply_nous_managed_defaults
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
|
||||
enabled = _get_platform_tools(
|
||||
cfg, "cli", include_default_mcp_servers=False
|
||||
)
|
||||
changed = apply_nous_managed_defaults(
|
||||
cfg,
|
||||
enabled_toolsets=enabled,
|
||||
force_fresh=True,
|
||||
)
|
||||
gateway_tools = sorted(changed)
|
||||
except Exception:
|
||||
# Portal lookup hiccups / non-subscriber / non-nous gating
|
||||
# must never block saving the model assignment.
|
||||
_log.debug("apply_nous_managed_defaults skipped", exc_info=True)
|
||||
|
||||
save_config(cfg)
|
||||
|
||||
# Surface auxiliary slots still pinned to a *different* provider than
|
||||
# the new main one. Switching the main model does NOT touch aux pins
|
||||
# (they're independent, sticky per-task overrides — see
|
||||
# auxiliary_client._resolve_auto). A user who switches main away from
|
||||
# a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s
|
||||
# on every background aux call until they reset those pins. We never
|
||||
# auto-clear them — pinning aux to a cheaper/different model is a
|
||||
# legitimate config — but we tell the caller so the UI can offer a
|
||||
# "reset to main" nudge instead of silently burning credits.
|
||||
new_provider = provider.strip().lower()
|
||||
stale_aux: list[dict] = []
|
||||
aux_cfg = cfg.get("auxiliary", {})
|
||||
if isinstance(aux_cfg, dict):
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux_cfg.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
continue
|
||||
slot_provider = str(slot_cfg.get("provider", "") or "").strip()
|
||||
if (
|
||||
slot_provider
|
||||
and slot_provider.lower() not in {"auto", ""}
|
||||
and slot_provider.lower() != new_provider
|
||||
):
|
||||
stale_aux.append({
|
||||
"task": slot,
|
||||
"provider": slot_provider,
|
||||
"model": str(slot_cfg.get("model", "") or ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"scope": "main",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"base_url": model_cfg.get("base_url", ""),
|
||||
"gateway_tools": gateway_tools,
|
||||
"stale_aux": stale_aux,
|
||||
}
|
||||
|
||||
# scope == "auxiliary"
|
||||
aux = cfg.get("auxiliary")
|
||||
if not isinstance(aux, dict):
|
||||
aux = {}
|
||||
|
||||
if task == "__reset__":
|
||||
# Reset every slot to provider="auto", model="" — keeps other fields intact.
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = "auto"
|
||||
slot_cfg["model"] = ""
|
||||
aux[slot] = slot_cfg
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {"ok": True, "scope": "auxiliary", "reset": True}
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=400, detail="provider required for auxiliary")
|
||||
|
||||
targets = [task] if task else list(_AUX_TASK_SLOTS)
|
||||
for slot in targets:
|
||||
if slot not in _AUX_TASK_SLOTS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = provider
|
||||
slot_cfg["model"] = model
|
||||
aux[slot] = slot_cfg
|
||||
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {
|
||||
"ok": True,
|
||||
"scope": "auxiliary",
|
||||
"tasks": targets,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
return await asyncio.to_thread(_apply_assignment)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
|
|
@ -2941,6 +2838,138 @@ async def set_model_assignment(body: ModelAssignment):
|
|||
raise HTTPException(status_code=500, detail="Failed to save model assignment")
|
||||
|
||||
|
||||
def _apply_model_assignment_sync(
|
||||
scope: str, provider: str, model: str, task: str, base_url: str
|
||||
):
|
||||
"""Synchronous body of POST /api/model/set.
|
||||
|
||||
Runs inside ``_profile_scope`` (in a worker thread) so every
|
||||
load_config/save_config lands in the requested profile. Raises
|
||||
HTTPException for validation errors — the async wrapper re-raises them.
|
||||
"""
|
||||
cfg = load_config()
|
||||
|
||||
if scope == "main":
|
||||
if not provider or not model:
|
||||
raise HTTPException(status_code=400, detail="provider and model required for main")
|
||||
model_cfg = _apply_main_model_assignment(
|
||||
cfg.get("model", {}), provider, model, base_url
|
||||
)
|
||||
cfg["model"] = model_cfg
|
||||
|
||||
# When switching the main provider to Nous, mirror the CLI's
|
||||
# post-model-selection behaviour (hermes_cli/main.py
|
||||
# prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults):
|
||||
# auto-route any *unconfigured* tools through the Nous Tool Gateway.
|
||||
# This is purely additive — apply_nous_managed_defaults skips every
|
||||
# tool where the user already has a direct key (FIRECRAWL_API_KEY,
|
||||
# FAL_KEY, etc.) or an explicit backend/provider in config, so it
|
||||
# never overwrites a user's own setup. GUI users thus land on the
|
||||
# gateway the same way CLI users do, without a separate prompt.
|
||||
gateway_tools: list[str] = []
|
||||
if provider.strip().lower() == "nous":
|
||||
try:
|
||||
from hermes_cli.nous_subscription import apply_nous_managed_defaults
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
|
||||
enabled = _get_platform_tools(
|
||||
cfg, "cli", include_default_mcp_servers=False
|
||||
)
|
||||
changed = apply_nous_managed_defaults(
|
||||
cfg,
|
||||
enabled_toolsets=enabled,
|
||||
force_fresh=True,
|
||||
)
|
||||
gateway_tools = sorted(changed)
|
||||
except Exception:
|
||||
# Portal lookup hiccups / non-subscriber / non-nous gating
|
||||
# must never block saving the model assignment.
|
||||
_log.debug("apply_nous_managed_defaults skipped", exc_info=True)
|
||||
|
||||
save_config(cfg)
|
||||
|
||||
# Surface auxiliary slots still pinned to a *different* provider than
|
||||
# the new main one. Switching the main model does NOT touch aux pins
|
||||
# (they're independent, sticky per-task overrides — see
|
||||
# auxiliary_client._resolve_auto). A user who switches main away from
|
||||
# a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s
|
||||
# on every background aux call until they reset those pins. We never
|
||||
# auto-clear them — pinning aux to a cheaper/different model is a
|
||||
# legitimate config — but we tell the caller so the UI can offer a
|
||||
# "reset to main" nudge instead of silently burning credits.
|
||||
new_provider = provider.strip().lower()
|
||||
stale_aux: list[dict] = []
|
||||
aux_cfg = cfg.get("auxiliary", {})
|
||||
if isinstance(aux_cfg, dict):
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux_cfg.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
continue
|
||||
slot_provider = str(slot_cfg.get("provider", "") or "").strip()
|
||||
if (
|
||||
slot_provider
|
||||
and slot_provider.lower() not in {"auto", ""}
|
||||
and slot_provider.lower() != new_provider
|
||||
):
|
||||
stale_aux.append({
|
||||
"task": slot,
|
||||
"provider": slot_provider,
|
||||
"model": str(slot_cfg.get("model", "") or ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"scope": "main",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"base_url": model_cfg.get("base_url", ""),
|
||||
"gateway_tools": gateway_tools,
|
||||
"stale_aux": stale_aux,
|
||||
}
|
||||
|
||||
# scope == "auxiliary"
|
||||
aux = cfg.get("auxiliary")
|
||||
if not isinstance(aux, dict):
|
||||
aux = {}
|
||||
|
||||
if task == "__reset__":
|
||||
# Reset every slot to provider="auto", model="" — keeps other fields intact.
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = "auto"
|
||||
slot_cfg["model"] = ""
|
||||
aux[slot] = slot_cfg
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {"ok": True, "scope": "auxiliary", "reset": True}
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=400, detail="provider required for auxiliary")
|
||||
|
||||
targets = [task] if task else list(_AUX_TASK_SLOTS)
|
||||
for slot in targets:
|
||||
if slot not in _AUX_TASK_SLOTS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = provider
|
||||
slot_cfg["model"] = model
|
||||
aux[slot] = slot_cfg
|
||||
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {
|
||||
"ok": True,
|
||||
"scope": "auxiliary",
|
||||
"tasks": targets,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
|
@ -2996,18 +3025,22 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
|
||||
|
||||
@app.put("/api/config")
|
||||
async def update_config(body: ConfigUpdate):
|
||||
async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
|
||||
try:
|
||||
save_config(_denormalize_config_from_web(body.config))
|
||||
with _profile_scope(profile or body.profile):
|
||||
save_config(_denormalize_config_from_web(body.config))
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("PUT /api/config failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@app.get("/api/env")
|
||||
async def get_env_vars():
|
||||
env_on_disk = load_env()
|
||||
async def get_env_vars(profile: Optional[str] = None):
|
||||
with _profile_scope(profile):
|
||||
env_on_disk = load_env()
|
||||
channel_keys = _channel_managed_env_keys()
|
||||
result = {}
|
||||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||||
|
|
@ -3030,9 +3063,10 @@ async def get_env_vars():
|
|||
|
||||
|
||||
@app.put("/api/env")
|
||||
async def set_env_var(body: EnvVarUpdate):
|
||||
async def set_env_var(body: EnvVarUpdate, profile: Optional[str] = None):
|
||||
try:
|
||||
save_env_value(body.key, body.value)
|
||||
with _profile_scope(profile or body.profile):
|
||||
save_env_value(body.key, body.value)
|
||||
return {"ok": True, "key": body.key}
|
||||
except ValueError as exc:
|
||||
# save_env_value raises ValueError for invalid names and for keys
|
||||
|
|
@ -3143,9 +3177,10 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
|
|||
|
||||
|
||||
@app.delete("/api/env")
|
||||
async def remove_env_var(body: EnvVarDelete):
|
||||
async def remove_env_var(body: EnvVarDelete, profile: Optional[str] = None):
|
||||
try:
|
||||
removed = remove_env_value(body.key)
|
||||
with _profile_scope(profile or body.profile):
|
||||
removed = remove_env_value(body.key)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
|
||||
return {"ok": True, "key": body.key}
|
||||
|
|
@ -3162,7 +3197,9 @@ async def remove_env_var(body: EnvVarDelete):
|
|||
|
||||
|
||||
@app.post("/api/env/reveal")
|
||||
async def reveal_env_var(body: EnvVarReveal, request: Request):
|
||||
async def reveal_env_var(
|
||||
body: EnvVarReveal, request: Request, profile: Optional[str] = None
|
||||
):
|
||||
"""Return the real (unredacted) value of a single env var.
|
||||
|
||||
Protected by:
|
||||
|
|
@ -3182,7 +3219,8 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
|
|||
_reveal_timestamps.append(now)
|
||||
|
||||
# --- Reveal ---
|
||||
env_on_disk = load_env()
|
||||
with _profile_scope(profile or body.profile):
|
||||
env_on_disk = load_env()
|
||||
value = env_on_disk.get(body.key)
|
||||
if value is None:
|
||||
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
|
||||
|
|
@ -6387,6 +6425,7 @@ class MCPServerCreate(BaseModel):
|
|||
env: Dict[str, str] = {}
|
||||
# auth: "oauth" | "header" | None
|
||||
auth: Optional[str] = None
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]:
|
||||
|
|
@ -6417,10 +6456,11 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|||
|
||||
|
||||
@app.get("/api/mcp/servers")
|
||||
async def list_mcp_servers():
|
||||
async def list_mcp_servers(profile: Optional[str] = None):
|
||||
from hermes_cli.mcp_config import _get_mcp_servers
|
||||
|
||||
servers = _get_mcp_servers()
|
||||
with _profile_scope(profile):
|
||||
servers = _get_mcp_servers()
|
||||
return {
|
||||
"servers": [
|
||||
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
|
||||
|
|
@ -6429,13 +6469,15 @@ async def list_mcp_servers():
|
|||
|
||||
|
||||
@app.post("/api/mcp/servers")
|
||||
async def add_mcp_server(body: MCPServerCreate):
|
||||
async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
|
||||
from hermes_cli.mcp_config import _get_mcp_servers, _save_mcp_server
|
||||
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Server name is required")
|
||||
if name in _get_mcp_servers():
|
||||
with _profile_scope(profile or body.profile):
|
||||
existing = _get_mcp_servers()
|
||||
if name in existing:
|
||||
raise HTTPException(status_code=409, detail=f"Server '{name}' already exists")
|
||||
if not body.url and not body.command:
|
||||
raise HTTPException(
|
||||
|
|
@ -6456,7 +6498,10 @@ async def add_mcp_server(body: MCPServerCreate):
|
|||
server_config["auth"] = body.auth
|
||||
|
||||
try:
|
||||
_save_mcp_server(name, server_config)
|
||||
with _profile_scope(profile or body.profile):
|
||||
_save_mcp_server(name, server_config)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("POST /api/mcp/servers failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
@ -6465,20 +6510,23 @@ async def add_mcp_server(body: MCPServerCreate):
|
|||
|
||||
|
||||
@app.delete("/api/mcp/servers/{name}")
|
||||
async def remove_mcp_server(name: str):
|
||||
async def remove_mcp_server(name: str, profile: Optional[str] = None):
|
||||
from hermes_cli.mcp_config import _remove_mcp_server
|
||||
|
||||
if not _remove_mcp_server(name):
|
||||
with _profile_scope(profile):
|
||||
removed = _remove_mcp_server(name)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/mcp/servers/{name}/test")
|
||||
async def test_mcp_server(name: str):
|
||||
async def test_mcp_server(name: str, profile: Optional[str] = None):
|
||||
"""Connect to the server, list its tools, disconnect. Returns tool list."""
|
||||
from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server
|
||||
|
||||
servers = _get_mcp_servers()
|
||||
with _profile_scope(profile):
|
||||
servers = _get_mcp_servers()
|
||||
if name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
|
||||
|
|
@ -6500,34 +6548,40 @@ async def test_mcp_server(name: str):
|
|||
|
||||
class MCPEnabledToggle(BaseModel):
|
||||
enabled: bool
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.put("/api/mcp/servers/{name}/enabled")
|
||||
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
|
||||
async def set_mcp_server_enabled(
|
||||
name: str, body: MCPEnabledToggle, profile: Optional[str] = None
|
||||
):
|
||||
"""Enable or disable an MCP server (takes effect on next session/gateway).
|
||||
|
||||
Toggles the ``enabled`` key on the server's config.yaml entry — the same
|
||||
flag the agent reads at startup. Disabled servers stay in config so they
|
||||
can be re-enabled without re-entering their settings.
|
||||
"""
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers")
|
||||
if not isinstance(servers, dict) or name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
if not isinstance(servers[name], dict):
|
||||
raise HTTPException(status_code=400, detail="Malformed server config")
|
||||
servers[name]["enabled"] = bool(body.enabled)
|
||||
save_config(cfg)
|
||||
with _profile_scope(profile or body.profile):
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers")
|
||||
if not isinstance(servers, dict) or name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
if not isinstance(servers[name], dict):
|
||||
raise HTTPException(status_code=400, detail="Malformed server config")
|
||||
servers[name]["enabled"] = bool(body.enabled)
|
||||
save_config(cfg)
|
||||
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
|
||||
|
||||
|
||||
@app.get("/api/mcp/catalog")
|
||||
async def list_mcp_catalog():
|
||||
async def list_mcp_catalog(profile: Optional[str] = None):
|
||||
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
|
||||
|
||||
Each entry reports whether it's already installed and enabled so the UI
|
||||
can show install / enabled state inline. This is the same catalog
|
||||
`hermes mcp catalog` / `hermes mcp install` read.
|
||||
`hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes
|
||||
the installed/enabled annotations (the catalog itself is repo-shipped
|
||||
and identical for every profile).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import mcp_catalog
|
||||
|
|
@ -6537,7 +6591,13 @@ async def list_mcp_catalog():
|
|||
|
||||
entries = []
|
||||
try:
|
||||
for entry in mcp_catalog.list_catalog():
|
||||
with _profile_scope(profile):
|
||||
catalog_entries = list(mcp_catalog.list_catalog())
|
||||
installed_state = {
|
||||
e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name))
|
||||
for e in catalog_entries
|
||||
}
|
||||
for entry in catalog_entries:
|
||||
auth = entry.auth
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
|
|
@ -6551,8 +6611,8 @@ async def list_mcp_catalog():
|
|||
for e in getattr(auth, "env", []) or []
|
||||
],
|
||||
"needs_install": entry.install is not None,
|
||||
"installed": mcp_catalog.is_installed(entry.name),
|
||||
"enabled": mcp_catalog.is_enabled(entry.name),
|
||||
"installed": installed_state.get(entry.name, (False, False))[0],
|
||||
"enabled": installed_state.get(entry.name, (False, False))[1],
|
||||
})
|
||||
except Exception:
|
||||
_log.exception("list_mcp_catalog failed")
|
||||
|
|
@ -6574,10 +6634,11 @@ class MCPCatalogInstall(BaseModel):
|
|||
# env: KEY=VALUE map for catalog entries that declare required env vars.
|
||||
env: Dict[str, str] = {}
|
||||
enable: bool = True
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/mcp/catalog/install")
|
||||
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
|
||||
async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None):
|
||||
"""Install a catalog MCP into config.yaml.
|
||||
|
||||
For HTTP/stdio entries with required env vars, those are written to .env
|
||||
|
|
@ -6594,23 +6655,42 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall):
|
|||
|
||||
# Persist any supplied env vars first (catalog entries declare which names
|
||||
# they need; we only write the ones the user provided).
|
||||
effective_profile = profile or body.profile
|
||||
if body.env:
|
||||
for k, v in body.env.items():
|
||||
if v:
|
||||
save_env_value(k, v)
|
||||
with _profile_scope(effective_profile):
|
||||
for k, v in body.env.items():
|
||||
if v:
|
||||
save_env_value(k, v)
|
||||
|
||||
# Git-bootstrap entries can take a while to clone — run via the background
|
||||
# action path so the request returns immediately and the UI can tail logs.
|
||||
# The -p subprocess rebinds HERMES_HOME-derived paths in the child.
|
||||
if entry.install is not None:
|
||||
try:
|
||||
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(effective_profile) + ["mcp", "install", name],
|
||||
"mcp-install",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
|
||||
return {"ok": True, "name": name, "background": True, "action": "mcp-install"}
|
||||
|
||||
# No git step — install synchronously via the catalog API.
|
||||
# No git step — install synchronously via the catalog API. install_entry
|
||||
# routes through load_config/save_config + save_env_value, all call-time
|
||||
# resolvers, so the context override scopes it. Wrap the to_thread body
|
||||
# in the scope INSIDE the thread (contextvars don't propagate into
|
||||
# to_thread the other way around — asyncio.to_thread copies context, so
|
||||
# setting it here works; keep it explicit for clarity).
|
||||
def _install_scoped():
|
||||
with _profile_scope(effective_profile):
|
||||
mcp_catalog.install_entry(entry, enable=body.enable)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
|
||||
await asyncio.to_thread(_install_scoped)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("install_mcp_catalog_entry failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
|
@ -7394,13 +7474,13 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
|
|||
|
||||
|
||||
@app.post("/api/skills/hub/install")
|
||||
async def install_skill_hub(body: SkillInstallRequest):
|
||||
async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None):
|
||||
identifier = (body.identifier or "").strip()
|
||||
if not identifier:
|
||||
raise HTTPException(status_code=400, detail="identifier is required")
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(body.profile) + ["skills", "install", identifier],
|
||||
_profile_cli_args(profile or body.profile) + ["skills", "install", identifier],
|
||||
"skills-install",
|
||||
)
|
||||
except HTTPException:
|
||||
|
|
@ -7417,13 +7497,13 @@ class SkillUninstallRequest(BaseModel):
|
|||
|
||||
|
||||
@app.post("/api/skills/hub/uninstall")
|
||||
async def uninstall_skill_hub(body: SkillUninstallRequest):
|
||||
async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None):
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(body.profile) + ["skills", "uninstall", name, "--yes"],
|
||||
_profile_cli_args(profile or body.profile) + ["skills", "uninstall", name, "--yes"],
|
||||
"skills-uninstall",
|
||||
)
|
||||
except HTTPException:
|
||||
|
|
@ -7439,11 +7519,13 @@ class SkillsUpdateRequest(BaseModel):
|
|||
|
||||
|
||||
@app.post("/api/skills/hub/update")
|
||||
async def update_skills_hub(body: Optional[SkillsUpdateRequest] = None):
|
||||
async def update_skills_hub(
|
||||
body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None
|
||||
):
|
||||
try:
|
||||
profile = body.profile if body else None
|
||||
effective = profile or (body.profile if body else None)
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(profile) + ["skills", "update"], "skills-update"
|
||||
_profile_cli_args(effective) + ["skills", "update"], "skills-update"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -8461,9 +8543,9 @@ async def get_skills(profile: Optional[str] = None):
|
|||
|
||||
|
||||
@app.put("/api/skills/toggle")
|
||||
async def toggle_skill(body: SkillToggle):
|
||||
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
|
||||
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
|
||||
with _profile_scope(body.profile):
|
||||
with _profile_scope(profile or body.profile):
|
||||
config = load_config()
|
||||
disabled = get_disabled_skills(config)
|
||||
if body.enabled:
|
||||
|
|
@ -8516,7 +8598,7 @@ class ToolsetToggle(BaseModel):
|
|||
|
||||
|
||||
@app.put("/api/tools/toolsets/{name}")
|
||||
async def toggle_toolset(name: str, body: ToolsetToggle):
|
||||
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
|
||||
"""Enable/disable a configurable toolset for the desktop (cli) platform.
|
||||
|
||||
Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools``
|
||||
|
|
@ -8534,7 +8616,7 @@ async def toggle_toolset(name: str, body: ToolsetToggle):
|
|||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
with _profile_scope(body.profile):
|
||||
with _profile_scope(profile or body.profile):
|
||||
config = load_config()
|
||||
enabled = set(
|
||||
_get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
||||
|
|
@ -8616,7 +8698,9 @@ class ToolsetProviderSelect(BaseModel):
|
|||
|
||||
|
||||
@app.put("/api/tools/toolsets/{name}/provider")
|
||||
async def select_toolset_provider(name: str, body: ToolsetProviderSelect):
|
||||
async def select_toolset_provider(
|
||||
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None
|
||||
):
|
||||
"""Persist a provider selection for a toolset (no key prompting).
|
||||
|
||||
Delegates to ``apply_provider_selection`` — the shared, non-interactive
|
||||
|
|
@ -8634,7 +8718,7 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect):
|
|||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
with _profile_scope(body.profile):
|
||||
with _profile_scope(profile or body.profile):
|
||||
config = load_config()
|
||||
try:
|
||||
apply_provider_selection(name, body.provider, config)
|
||||
|
|
@ -8650,7 +8734,7 @@ class ToolsetEnvUpdate(BaseModel):
|
|||
|
||||
|
||||
@app.put("/api/tools/toolsets/{name}/env")
|
||||
async def save_toolset_env(name: str, body: ToolsetEnvUpdate):
|
||||
async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None):
|
||||
"""Persist API keys for a toolset's provider env vars.
|
||||
|
||||
Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value`` —
|
||||
|
|
@ -8672,7 +8756,7 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate):
|
|||
if name not in valid_ts:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
with _profile_scope(body.profile):
|
||||
with _profile_scope(profile or body.profile):
|
||||
config = load_config()
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
allowed: set[str] = set()
|
||||
|
|
@ -8753,23 +8837,26 @@ async def run_toolset_post_setup(name: str, body: ToolsetPostSetup):
|
|||
|
||||
class RawConfigUpdate(BaseModel):
|
||||
yaml_text: str
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/config/raw")
|
||||
async def get_config_raw():
|
||||
path = get_config_path()
|
||||
async def get_config_raw(profile: Optional[str] = None):
|
||||
with _profile_scope(profile):
|
||||
path = get_config_path()
|
||||
if not path.exists():
|
||||
return {"yaml": ""}
|
||||
return {"yaml": path.read_text(encoding="utf-8")}
|
||||
|
||||
|
||||
@app.put("/api/config/raw")
|
||||
async def update_config_raw(body: RawConfigUpdate):
|
||||
async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None):
|
||||
try:
|
||||
parsed = yaml.safe_load(body.yaml_text)
|
||||
if not isinstance(parsed, dict):
|
||||
raise HTTPException(status_code=400, detail="YAML must be a mapping")
|
||||
save_config(parsed)
|
||||
with _profile_scope(profile or body.profile):
|
||||
save_config(parsed)
|
||||
return {"ok": True}
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
|
||||
|
|
@ -9217,6 +9304,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
|||
def _resolve_chat_argv(
|
||||
resume: Optional[str] = None,
|
||||
sidecar_url: Optional[str] = None,
|
||||
profile: Optional[str] = None,
|
||||
) -> tuple[list[str], Optional[str], Optional[dict]]:
|
||||
"""Resolve the argv + cwd + env for the chat PTY.
|
||||
|
||||
|
|
@ -9236,9 +9324,24 @@ def _resolve_chat_argv(
|
|||
`sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so
|
||||
the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the
|
||||
dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`).
|
||||
|
||||
`profile` (when set) scopes the ENTIRE chat to that profile by pointing
|
||||
``HERMES_HOME`` at the profile dir in the child env. Every spawned
|
||||
process (the TUI and the ``tui_gateway.entry`` it launches) resolves
|
||||
``get_hermes_home()`` from that env var at its own import, so the child
|
||||
binds the profile's config, skills, memory, and state.db from the start
|
||||
— the same propagation ``hermes -p <name>`` performs. The in-process
|
||||
``HERMES_TUI_GATEWAY_URL`` attach is SKIPPED for scoped chats: the
|
||||
dashboard's in-memory gateway runs under the dashboard's own profile,
|
||||
so a profile-scoped chat must spawn its own gateway subprocess.
|
||||
"""
|
||||
from hermes_cli.main import PROJECT_ROOT, _make_tui_argv
|
||||
|
||||
profile_dir: Optional[Path] = None
|
||||
requested = (profile or "").strip()
|
||||
if requested and requested.lower() != "current":
|
||||
profile_dir = _resolve_profile_dir(requested)
|
||||
|
||||
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||
env = os.environ.copy()
|
||||
try:
|
||||
|
|
@ -9256,6 +9359,9 @@ def _resolve_chat_argv(
|
|||
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
|
||||
env.setdefault("HERMES_TUI_INLINE", "1")
|
||||
|
||||
if profile_dir is not None:
|
||||
env["HERMES_HOME"] = str(profile_dir)
|
||||
|
||||
if resume:
|
||||
latest_resume, _latest_path = _session_latest_descendant(resume)
|
||||
if latest_resume:
|
||||
|
|
@ -9265,8 +9371,13 @@ def _resolve_chat_argv(
|
|||
if sidecar_url:
|
||||
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
||||
|
||||
if gateway_ws_url := _build_gateway_ws_url():
|
||||
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
|
||||
# Profile-scoped chats must NOT attach to the dashboard's in-memory
|
||||
# gateway — it runs under the dashboard's own profile. Without the
|
||||
# attach URL, gatewayClient spawns its own `tui_gateway.entry`, which
|
||||
# inherits the profile HERMES_HOME set above.
|
||||
if profile_dir is None:
|
||||
if gateway_ws_url := _build_gateway_ws_url():
|
||||
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
|
||||
|
||||
return list(argv), str(cwd) if cwd else None, env
|
||||
|
||||
|
|
@ -9429,11 +9540,19 @@ async def pty_ws(ws: WebSocket) -> None:
|
|||
|
||||
# --- spawn PTY ------------------------------------------------------
|
||||
resume = ws.query_params.get("resume") or None
|
||||
profile = ws.query_params.get("profile") or None
|
||||
channel = _channel_or_close_code(ws)
|
||||
sidecar_url = _build_sidecar_url(channel) if channel else None
|
||||
|
||||
try:
|
||||
argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url)
|
||||
argv, cwd, env = _resolve_chat_argv(
|
||||
resume=resume, sidecar_url=sidecar_url, profile=profile
|
||||
)
|
||||
except HTTPException as exc:
|
||||
# Unknown/invalid profile from _resolve_profile_dir.
|
||||
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc.detail}\x1b[0m\r\n")
|
||||
await ws.close(code=1011)
|
||||
return
|
||||
except SystemExit as exc:
|
||||
# _make_tui_argv calls sys.exit(1) when node/npm is missing.
|
||||
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n")
|
||||
|
|
@ -10711,8 +10830,15 @@ def start_server(
|
|||
port: int = 9119,
|
||||
open_browser: bool = True,
|
||||
allow_public: bool = False,
|
||||
initial_profile: str = "",
|
||||
):
|
||||
"""Start the web UI server."""
|
||||
"""Start the web UI server.
|
||||
|
||||
``initial_profile`` (when set) is appended to the auto-opened browser
|
||||
URL as ``?profile=<name>`` so the SPA's profile switcher preselects it
|
||||
— used when a profile alias (``<profile> dashboard``) routes to the
|
||||
machine dashboard.
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
# Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token
|
||||
|
|
@ -10803,10 +10929,15 @@ def start_server(
|
|||
)
|
||||
|
||||
if _has_display:
|
||||
_open_url = f"http://{host}:{port}"
|
||||
if initial_profile:
|
||||
from urllib.parse import quote
|
||||
_open_url += f"/?profile={quote(initial_profile)}"
|
||||
|
||||
def _open():
|
||||
try:
|
||||
time.sleep(1.0)
|
||||
webbrowser.open(f"http://{host}:{port}")
|
||||
webbrowser.open(_open_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
114
tests/hermes_cli/test_dashboard_unified_launch.py
Normal file
114
tests/hermes_cli/test_dashboard_unified_launch.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for the unified profile→machine dashboard launch routing.
|
||||
|
||||
`<profile> dashboard` routes to ONE machine-level dashboard instead of
|
||||
spawning a per-profile server: attach (open browser at ?profile=) when one
|
||||
is already listening, else re-exec as the machine dashboard with the
|
||||
launching profile preselected. `--isolated` opts out.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod():
|
||||
import hermes_cli.main as main_mod
|
||||
return main_mod
|
||||
|
||||
|
||||
def _args(**kw):
|
||||
defaults = dict(
|
||||
status=False, stop=False, host="127.0.0.1", port=9119,
|
||||
no_open=True, insecure=False, skip_build=False,
|
||||
isolated=False, open_profile="",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return types.SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
class TestUnifiedDashboardRouting:
|
||||
def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
|
||||
execs = []
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_dashboard(_args())
|
||||
assert exc.value.code == 0
|
||||
assert execs == [] # attached, never re-exec'd
|
||||
|
||||
def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False)
|
||||
execs = []
|
||||
|
||||
def fake_exec(exe, argv, env):
|
||||
execs.append((exe, argv, env))
|
||||
raise SystemExit(0) # execvpe never returns
|
||||
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", fake_exec)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_dashboard(_args())
|
||||
|
||||
assert len(execs) == 1
|
||||
exe, argv, env = execs[0]
|
||||
assert exe == sys.executable
|
||||
# Pinned to the default profile + launching profile preselected.
|
||||
assert "-p" in argv and argv[argv.index("-p") + 1] == "default"
|
||||
assert "--open-profile" in argv
|
||||
assert argv[argv.index("--open-profile") + 1] == "worker_x"
|
||||
# Profile HERMES_HOME dropped so the child binds the machine root.
|
||||
assert "HERMES_HOME" not in env
|
||||
|
||||
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
listening_calls = []
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_dashboard_listening",
|
||||
lambda host, port: listening_calls.append(1) or True,
|
||||
)
|
||||
# With --isolated the routing block is skipped entirely; the command
|
||||
# proceeds to dependency checks. Make the first post-routing step
|
||||
# bail so the test doesn't actually start a server.
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args(isolated=True))
|
||||
assert listening_calls == []
|
||||
|
||||
def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
|
||||
)
|
||||
listening_calls = []
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_dashboard_listening",
|
||||
lambda host, port: listening_calls.append(1) or True,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args())
|
||||
assert listening_calls == []
|
||||
|
||||
def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch):
|
||||
"""The re-exec'd child carries --open-profile; the guard must treat
|
||||
that as 'already routed' and never re-exec again (no exec loop)."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
execs = []
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
|
||||
assert execs == []
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
236
tests/hermes_cli/test_web_server_profile_unification.py
Normal file
236
tests/hermes_cli/test_web_server_profile_unification.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""Regression tests for the machine-dashboard multi-profile unification.
|
||||
|
||||
The dashboard is ONE machine-level management surface: config, env, MCP,
|
||||
model, and chat-PTY endpoints accept an optional ``profile`` so the global
|
||||
profile switcher can target any profile's HERMES_HOME. These tests pin:
|
||||
reads/writes land in the REQUESTED profile, the dashboard's own profile
|
||||
stays untouched, and the chat PTY env is scoped via HERMES_HOME.
|
||||
"""
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||
"""Isolated default home + one named profile, each with config + .env."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import profiles
|
||||
|
||||
default_home = get_hermes_home()
|
||||
profiles_root = default_home / "profiles"
|
||||
worker_home = profiles_root / "worker_beta"
|
||||
for home in (default_home, worker_home):
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
(worker_home / ".env").write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
return {"default": default_home, "worker_beta": worker_home}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, isolated_profiles):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
def _cfg(home):
|
||||
return yaml.safe_load((home / "config.yaml").read_text()) or {}
|
||||
|
||||
|
||||
class TestProfileScopedConfig:
|
||||
def test_config_put_lands_in_target_profile_only(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/config",
|
||||
json={"config": {"timezone": "Mars/Olympus"}, "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Mars/Olympus"
|
||||
assert _cfg(isolated_profiles["default"]).get("timezone") != "Mars/Olympus"
|
||||
|
||||
def test_config_get_reads_target_profile(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"timezone: Venus/Cloud\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.get("/api/config", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("timezone") == "Venus/Cloud"
|
||||
# Unscoped read sees the dashboard's own config.
|
||||
resp = client.get("/api/config")
|
||||
assert resp.json().get("timezone") != "Venus/Cloud"
|
||||
|
||||
def test_config_query_param_equivalent_to_body(self, client, isolated_profiles):
|
||||
"""The SPA's fetchJSON injects ?profile= — must scope like body.profile."""
|
||||
resp = client.put(
|
||||
"/api/config?profile=worker_beta",
|
||||
json={"config": {"timezone": "Pluto/Far"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far"
|
||||
assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far"
|
||||
|
||||
def test_config_raw_round_trip_scoped(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/config/raw",
|
||||
json={"yaml_text": "timezone: Io/Volcano\n", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = client.get("/api/config/raw", params={"profile": "worker_beta"})
|
||||
assert "Io/Volcano" in resp.json()["yaml"]
|
||||
resp = client.get("/api/config/raw")
|
||||
assert "Io/Volcano" not in resp.json()["yaml"]
|
||||
|
||||
def test_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/config", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestProfileScopedEnv:
|
||||
def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/env",
|
||||
json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
assert "test-fal-123" in worker_env
|
||||
default_env_path = isolated_profiles["default"] / ".env"
|
||||
if default_env_path.exists():
|
||||
assert "test-fal-123" not in default_env_path.read_text()
|
||||
|
||||
def test_env_list_reads_target_profile(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / ".env").write_text(
|
||||
"FAL_KEY=worker-only-value\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.get("/api/env", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["FAL_KEY"]["is_set"] is True
|
||||
resp = client.get("/api/env")
|
||||
assert resp.json()["FAL_KEY"]["is_set"] is False
|
||||
|
||||
def test_env_delete_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / ".env").write_text(
|
||||
"FAL_KEY=doomed\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
"/api/env",
|
||||
json={"key": "FAL_KEY", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
|
||||
|
||||
class TestProfileScopedMcp:
|
||||
def test_mcp_add_and_list_scoped(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/mcp/servers",
|
||||
json={"name": "scoped-srv", "url": "http://localhost:1234/sse",
|
||||
"profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
assert "scoped-srv" in worker_cfg.get("mcp_servers", {})
|
||||
assert "scoped-srv" not in _cfg(isolated_profiles["default"]).get("mcp_servers", {})
|
||||
|
||||
listing = client.get("/api/mcp/servers", params={"profile": "worker_beta"}).json()
|
||||
assert any(s["name"] == "scoped-srv" for s in listing["servers"])
|
||||
listing = client.get("/api/mcp/servers").json()
|
||||
assert not any(s["name"] == "scoped-srv" for s in listing["servers"])
|
||||
|
||||
def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.put(
|
||||
"/api/mcp/servers/srv1/enabled",
|
||||
json={"enabled": False, "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
assert worker_cfg["mcp_servers"]["srv1"]["enabled"] is False
|
||||
|
||||
def test_mcp_remove_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n srv2:\n url: http://x/sse\n", encoding="utf-8"
|
||||
)
|
||||
# Removing from the DASHBOARD's profile must 404 (srv2 lives in worker).
|
||||
resp = client.delete("/api/mcp/servers/srv2")
|
||||
assert resp.status_code == 404
|
||||
resp = client.delete("/api/mcp/servers/srv2", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert "srv2" not in _cfg(isolated_profiles["worker_beta"]).get("mcp_servers", {})
|
||||
|
||||
|
||||
class TestProfileScopedModel:
|
||||
def test_model_set_main_scoped(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "openrouter",
|
||||
"model": "test/model-1",
|
||||
"confirm_expensive_model": True,
|
||||
"profile": "worker_beta",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
model_cfg = worker_cfg.get("model", {})
|
||||
assert isinstance(model_cfg, dict)
|
||||
assert model_cfg.get("provider") == "openrouter"
|
||||
default_model = _cfg(isolated_profiles["default"]).get("model", {})
|
||||
if isinstance(default_model, dict):
|
||||
assert default_model.get("default") != "test/model-1"
|
||||
|
||||
|
||||
class TestProfileScopedChatPty:
|
||||
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
|
||||
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
|
||||
# Scoped chat must NOT attach to the dashboard's in-memory gateway.
|
||||
assert "HERMES_TUI_GATEWAY_URL" not in env
|
||||
|
||||
def test_chat_argv_unscoped_keeps_legacy_env(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
argv, cwd, env = web_server._resolve_chat_argv()
|
||||
assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"])
|
||||
|
||||
def test_chat_argv_unknown_profile_raises(self, isolated_profiles, monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
web_server._resolve_chat_argv(profile="ghost")
|
||||
assert exc.value.status_code == 404
|
||||
|
|
@ -64,6 +64,9 @@ import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint
|
|||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { AuthWidget } from "@/components/AuthWidget";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { ProfileProvider } from "@/contexts/ProfileProvider";
|
||||
import { ProfileSwitcher } from "@/components/ProfileSwitcher";
|
||||
import { ProfileScopeBanner } from "@/components/ProfileScopeBanner";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
|
|
@ -474,6 +477,7 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<ProfileProvider>
|
||||
<div
|
||||
data-layout-variant={layoutVariant}
|
||||
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
|
||||
|
|
@ -528,6 +532,7 @@ export default function App() {
|
|||
)}
|
||||
|
||||
<PluginSlot name="header-banner" />
|
||||
<ProfileScopeBanner />
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
|
|
@ -602,6 +607,8 @@ export default function App() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<ProfileSwitcher collapsed={isDesktopCollapsed} />
|
||||
|
||||
<nav
|
||||
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
|
||||
aria-label={t.app.navigation}
|
||||
|
|
@ -775,6 +782,7 @@ export default function App() {
|
|||
|
||||
<PluginSlot name="overlay" />
|
||||
</div>
|
||||
</ProfileProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
27
web/src/components/ProfileScopeBanner.tsx
Normal file
27
web/src/components/ProfileScopeBanner.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Users } from "lucide-react";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/**
|
||||
* App-wide amber banner shown while the global switcher targets a profile
|
||||
* OTHER than the dashboard's own — every management write (config, keys,
|
||||
* skills, MCPs, model) and new Chat sessions land in that profile.
|
||||
*/
|
||||
export function ProfileScopeBanner() {
|
||||
const { profile, currentProfile } = useProfileScope();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!profile || profile === currentProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-300">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{(
|
||||
t.app.managingProfileBanner ??
|
||||
"Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile."
|
||||
).replace("{name}", profile)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
web/src/components/ProfileSwitcher.tsx
Normal file
67
web/src/components/ProfileSwitcher.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Users } from "lucide-react";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The machine dashboard's single write-target selector.
|
||||
*
|
||||
* Rendered in the sidebar above the nav. Every management page (Config,
|
||||
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
|
||||
* fetchJSON ?profile= injection. Hidden when only one profile exists.
|
||||
*/
|
||||
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
|
||||
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (profiles.length < 2) return null;
|
||||
|
||||
const managed = profile || currentProfile || "default";
|
||||
const isOther = !!profile && profile !== currentProfile;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
|
||||
collapsed && "lg:justify-center lg:px-0",
|
||||
)}
|
||||
title={t.app.managingProfile ?? "Managing profile"}
|
||||
>
|
||||
<Users
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isOther ? "text-amber-300" : "text-text-tertiary",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
aria-label={t.app.managingProfile ?? "Managing profile"}
|
||||
className={cn(
|
||||
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
|
||||
isOther
|
||||
? "border-amber-500/50 text-amber-300"
|
||||
: "border-border text-text-secondary",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
|
||||
"{name}",
|
||||
currentProfile || "default",
|
||||
)}
|
||||
</option>
|
||||
{profiles
|
||||
.filter((name) => name !== currentProfile)
|
||||
.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{collapsed && (
|
||||
<span className="sr-only">{managed}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
web/src/contexts/ProfileProvider.tsx
Normal file
72
web/src/contexts/ProfileProvider.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { api, setManagementProfile } from "@/lib/api";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
/**
|
||||
* Machine-level management-profile scope.
|
||||
*
|
||||
* One switcher (rendered in the sidebar) decides which profile every
|
||||
* management page reads/writes. The selection lives in the URL
|
||||
* (`?profile=<name>`) so it survives refresh and deep-links, and is mirrored
|
||||
* into the api module so `fetchJSON` transparently appends it to the
|
||||
* profile-scoped endpoint families. "" = the dashboard's own profile.
|
||||
*
|
||||
* This exists because "Set as active" on the Profiles page only flips the
|
||||
* sticky active_profile file (future CLI/gateway runs) — it cannot retarget
|
||||
* the running dashboard. The switcher is the dashboard's own, visible,
|
||||
* write-target selector.
|
||||
*/
|
||||
export function ProfileProvider({ children }: { children: ReactNode }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [profiles, setProfiles] = useState<string[]>([]);
|
||||
const [currentProfile, setCurrentProfile] = useState("default");
|
||||
|
||||
const profile = searchParams.get("profile") ?? "";
|
||||
|
||||
// Mirror into the api module synchronously on every render where it
|
||||
// changed, so fetches fired by child effects in the same commit see it.
|
||||
setManagementProfile(profile);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getProfiles()
|
||||
.then((res) => setProfiles(res.profiles.map((p) => p.name)))
|
||||
.catch(() => {});
|
||||
api
|
||||
.getActiveProfile()
|
||||
.then((info) => setCurrentProfile(info.current || "default"))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setProfile = useCallback(
|
||||
(name: string) => {
|
||||
setManagementProfile(name);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (name) next.set("profile", name);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ profile, currentProfile, profiles, setProfile }),
|
||||
[profile, currentProfile, profiles, setProfile],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
|
||||
);
|
||||
}
|
||||
19
web/src/contexts/profile-context.ts
Normal file
19
web/src/contexts/profile-context.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createContext } from "react";
|
||||
|
||||
export interface ProfileContextValue {
|
||||
/** Profile every management surface reads/writes ("" = the dashboard
|
||||
* process's own profile). */
|
||||
profile: string;
|
||||
/** The profile the dashboard process itself runs under. */
|
||||
currentProfile: string;
|
||||
/** Known profile names (includes "default"). */
|
||||
profiles: string[];
|
||||
setProfile: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ProfileContext = createContext<ProfileContextValue>({
|
||||
profile: "",
|
||||
currentProfile: "default",
|
||||
profiles: [],
|
||||
setProfile: () => {},
|
||||
});
|
||||
6
web/src/contexts/useProfileScope.ts
Normal file
6
web/src/contexts/useProfileScope.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { useContext } from "react";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
export function useProfileScope() {
|
||||
return useContext(ProfileContext);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -41,11 +41,52 @@ function setSessionHeader(headers: Headers, token: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Global management-profile scope ──────────────────────────────────
|
||||
// The dashboard is a machine-level management surface: one header switcher
|
||||
// (ProfileProvider in App.tsx) decides which profile the management pages
|
||||
// read/write, and fetchJSON transparently appends ?profile=<name> to the
|
||||
// profile-scoped endpoint families below. "" = the dashboard process's own
|
||||
// profile (legacy behavior). Calls that already carry an explicit profile
|
||||
// (e.g. ProfileBuilder writes) are left untouched — explicit beats global.
|
||||
let _managementProfile = "";
|
||||
|
||||
export function setManagementProfile(name: string): void {
|
||||
_managementProfile = (name || "").trim();
|
||||
}
|
||||
|
||||
export function getManagementProfile(): string {
|
||||
return _managementProfile;
|
||||
}
|
||||
|
||||
// Endpoint families that honor ?profile= on the backend (web_server.py
|
||||
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
|
||||
// channels, cron (which has its own per-job profile params), profiles
|
||||
// themselves — is machine-global or self-scoped and must NOT be rewritten.
|
||||
const PROFILE_SCOPED_PREFIXES = [
|
||||
"/api/skills",
|
||||
"/api/tools/toolsets",
|
||||
"/api/config",
|
||||
"/api/env",
|
||||
"/api/mcp",
|
||||
"/api/model/info",
|
||||
"/api/model/set",
|
||||
];
|
||||
|
||||
function withManagementProfile(url: string): string {
|
||||
if (!_managementProfile) return url;
|
||||
if (url.includes("profile=")) return url; // explicit param wins
|
||||
const path = url.split("?")[0];
|
||||
if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url;
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`;
|
||||
}
|
||||
|
||||
export async function fetchJSON<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: FetchJSONOptions,
|
||||
): Promise<T> {
|
||||
url = withManagementProfile(url);
|
||||
// Inject the session token into all /api/ requests.
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
|
|
|
|||
|
|
@ -37,11 +37,13 @@ import { useI18n } from "@/i18n";
|
|||
import { api } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
|
||||
function buildWsUrl(
|
||||
authParam: [string, string],
|
||||
resume: string | null,
|
||||
channel: string,
|
||||
profile: string,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
|
|
@ -49,6 +51,10 @@ function buildWsUrl(
|
|||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
if (resume) qs.set("resume", resume);
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
// skills, memory, and sessions (see web_server._resolve_chat_argv).
|
||||
if (profile) qs.set("profile", profile);
|
||||
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +179,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
// treat the current resume target as part of the PTY identity and rebuild the
|
||||
// terminal session when it changes.
|
||||
const resumeParam = searchParams.get("resume");
|
||||
const channel = useMemo(() => generateChannelId(), [resumeParam]);
|
||||
// Profile-scoped chat: spawn the PTY under the globally selected
|
||||
// management profile. Changing it remounts the terminal (key below /
|
||||
// effect dep) so the user explicitly starts a fresh scoped session.
|
||||
const { profile: scopedProfile } = useProfileScope();
|
||||
const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeParam) return;
|
||||
|
|
@ -576,7 +586,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
void (async () => {
|
||||
const authParam = await buildWsAuthParam();
|
||||
if (unmounting) return;
|
||||
const url = buildWsUrl(authParam, resumeParam, channel);
|
||||
const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
|
@ -714,7 +724,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
copyResetRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [channel, resumeParam]);
|
||||
}, [channel, resumeParam, scopedProfile]);
|
||||
|
||||
// When the user returns to the chat tab (isActive: false → true), the
|
||||
// terminal host just transitioned from display:none to display:flex.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import {
|
|||
AlertTriangle,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
|
|
@ -36,9 +35,8 @@ import type {
|
|||
SkillHubInstalledEntry,
|
||||
SkillHubPreview,
|
||||
SkillHubScan,
|
||||
ProfileInfo,
|
||||
} from "@/lib/api";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
|
|
@ -137,51 +135,15 @@ export default function SkillsPage() {
|
|||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
// ── Profile scoping ──
|
||||
// The dashboard process runs under ONE profile, but skills/toolsets are
|
||||
// per-profile state. Without an explicit selector, users who "activated"
|
||||
// a profile on the Profiles page (which only affects FUTURE CLI/gateway
|
||||
// runs) toggled skills here and silently wrote into the dashboard's own
|
||||
// profile. The selector makes the write target explicit and deep-linkable
|
||||
// via /skills?profile=<name>.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [currentProfile, setCurrentProfile] = useState<string>("");
|
||||
const urlProfile = searchParams.get("profile") ?? "";
|
||||
// "" = the dashboard's own profile (legacy behavior).
|
||||
const selectedProfile = urlProfile;
|
||||
|
||||
const setSelectedProfile = useCallback(
|
||||
(name: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (name) next.set("profile", name);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// The profile actually being managed, for display purposes.
|
||||
const managedProfile = selectedProfile || currentProfile || "default";
|
||||
const managingOtherProfile =
|
||||
!!selectedProfile && selectedProfile !== currentProfile;
|
||||
|
||||
useEffect(() => {
|
||||
// Profile list + the dashboard's own profile, for the selector. Failure
|
||||
// leaves the selector hidden — the page still works profile-unscoped.
|
||||
api
|
||||
.getProfiles()
|
||||
.then((res) => setProfiles(res.profiles))
|
||||
.catch(() => {});
|
||||
api
|
||||
.getActiveProfile()
|
||||
.then((info) => setCurrentProfile(info.current || "default"))
|
||||
.catch(() => setCurrentProfile("default"));
|
||||
}, []);
|
||||
// The write target comes from the GLOBAL profile switcher (sidebar) via
|
||||
// ProfileContext — one selector for the whole dashboard, deep-linkable
|
||||
// as ?profile=<name>. This page just consumes it: the fetchJSON layer
|
||||
// appends the param automatically; we still pass it explicitly where the
|
||||
// call signature supports it (clearer, and robust if a caller bypasses
|
||||
// the auto-injection).
|
||||
const {
|
||||
profile: selectedProfile,
|
||||
} = useProfileScope();
|
||||
|
||||
useEffect(() => {
|
||||
// Promise-chain shape: setState fires only inside async callbacks so the
|
||||
|
|
@ -298,33 +260,6 @@ export default function SkillsPage() {
|
|||
{t.skills.enabledOf
|
||||
.replace("{enabled}", String(enabledCount))
|
||||
.replace("{total}", String(skills.length))}
|
||||
{profiles.length > 1 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<select
|
||||
aria-label={t.skills.profileSelector ?? "Profile"}
|
||||
className="h-6 rounded-none border border-border bg-background px-1 text-xs text-foreground"
|
||||
value={selectedProfile}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setSelectedProfile(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{(t.skills.currentProfile ?? "current ({name})").replace(
|
||||
"{name}",
|
||||
currentProfile || "default",
|
||||
)}
|
||||
</option>
|
||||
{profiles
|
||||
.filter((p) => p.name !== currentProfile)
|
||||
.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
)}
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
|
|
@ -361,10 +296,6 @@ export default function SkillsPage() {
|
|||
setEnd,
|
||||
skills.length,
|
||||
t,
|
||||
profiles,
|
||||
selectedProfile,
|
||||
currentProfile,
|
||||
setSelectedProfile,
|
||||
]);
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
|
|
@ -391,18 +322,6 @@ export default function SkillsPage() {
|
|||
<PluginSlot name="skills:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
{managingOtherProfile && (
|
||||
<div className="flex items-center gap-2 border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{(
|
||||
t.skills.managingProfile ??
|
||||
"Managing profile “{name}” — toggles apply to that profile, not this dashboard’s."
|
||||
).replace("{name}", managedProfile)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-0">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue