diff --git a/apps/desktop/src/api/system.ts b/apps/desktop/src/api/system.ts index 3e7675eac3..47d2636f2b 100644 --- a/apps/desktop/src/api/system.ts +++ b/apps/desktop/src/api/system.ts @@ -241,18 +241,29 @@ export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; // audit` / `hermes backup` / `hermes debug share` and the dashboard System // page). All except debug share are spawn-based background actions tailed via // getActionStatus(). +// +// Every one carries the ambient profile: Electron pins the whole /api/ops +// family to the shared primary backend (connection-config's +// LOCAL_PRIMARY_SCOPED_ROUTES), so an unprofiled call acts on that backend's +// LAUNCH profile — and debug share uploads a home's logs and config. // --------------------------------------------------------------------------- export function runDoctor(): Promise { - return hermesApi({ path: '/api/ops/doctor', method: 'POST', body: {} }) + return hermesApi({ ...profileScoped(), path: '/api/ops/doctor', method: 'POST', body: {} }) } export function runSecurityAudit(): Promise { - return hermesApi({ path: '/api/ops/security-audit', method: 'POST', body: {} }) + return hermesApi({ + ...profileScoped(), + path: '/api/ops/security-audit', + method: 'POST', + body: {} + }) } export function runBackup(): Promise { return hermesApi({ + ...profileScoped(), path: '/api/ops/backup', method: 'POST', body: {} @@ -261,6 +272,7 @@ export function runBackup(): Promise { export function runDebugShare(): Promise { return hermesApi({ + ...profileScoped(), path: '/api/ops/debug-share', method: 'POST', body: {}, diff --git a/hermes_cli/web_routers/_common.py b/hermes_cli/web_routers/_common.py index d85cd1dcf5..2399f90e24 100644 --- a/hermes_cli/web_routers/_common.py +++ b/hermes_cli/web_routers/_common.py @@ -58,16 +58,19 @@ async def config_scoped_to_thread(profile: Optional[str], fn: Callable[[], Any]) def destructive_profile(profile: Optional[str], route: str) -> Optional[str]: - """The profile a DESTRUCTIVE route acts on, or 400 when it is ambiguous. + """The profile a DESTRUCTIVE or PRIVILEGED route acts on, or 400 when it is ambiguous. - One backend serves every profile, so an omitted ``profile`` on a route that - deletes or overwrites profile-owned data is not a default — it silently meant + One backend serves every profile, so an omitted ``profile`` on a route that deletes, + overwrites or privileges profile-owned data is not a default — it silently meant "whichever home this process launched with". Named profile: honoured. Omitted: rejected as soon as the process hosts more than one profile (``is_multiplex_active()``, decided once at boot by ``activate_multi_profile_hosting_eagerly``). A genuinely single-profile host has nothing to confuse, so there an omitted profile keeps meaning the launch profile and `curl` against a plain ``hermes serve`` is unchanged. + + "Privileged" is the same class as "destructive": arming an auto-approved shell hook + in the wrong profile is at least as bad as removing one from it. """ if (profile or "").strip(): return profile diff --git a/hermes_cli/web_routers/dashboard_ui.py b/hermes_cli/web_routers/dashboard_ui.py index e5d40d08ff..b323fbf27d 100644 --- a/hermes_cli/web_routers/dashboard_ui.py +++ b/hermes_cli/web_routers/dashboard_ui.py @@ -276,15 +276,23 @@ async def delete_agent_plugin(request: Request, name: str): @router.put("/api/dashboard/plugin-providers") -async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody): +async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody, + profile: Optional[str] = None): """Persist memory provider / context engine selection (writes config.yaml).""" _require_token(request) from hermes_cli.plugins_cmd import _save_context_engine, _save_memory_provider def _run(): - with _CONFIG_MUTATION_LOCK: + # ``_save_memory_provider``/``_save_context_engine`` are functools.partial over + # ``_write_config_value`` -> save_config: the write is one hop away and lands in + # whatever home the scope names. Unlike plugin INSTALLATION (host venv, pinned to the + # serving profile), these two keys are per-profile settings — `memory.provider` is the + # same key PUT /api/memory/provider scopes — so they follow ``?profile=``. + with _config_profile_scope(profile), _CONFIG_MUTATION_LOCK: if body.memory_provider is not None: memory_provider = _normalize_memory_provider_name(body.memory_provider) + # Readiness resolves through load_config(); inside the scope so the answer is + # about the profile being written, not the launch profile. _require_memory_provider_ready(memory_provider) _save_memory_provider(memory_provider) if body.context_engine is not None: diff --git a/hermes_cli/web_routers/local_models.py b/hermes_cli/web_routers/local_models.py index 163b1cbc0e..bceb83bbd2 100644 --- a/hermes_cli/web_routers/local_models.py +++ b/hermes_cli/web_routers/local_models.py @@ -657,7 +657,7 @@ def _restart_on_new_tag(job: Dict[str, Any], tag: str, previous: list) -> bool: @router.post("/api/local-models/runtime/install") -async def local_models_runtime_install(body: RuntimeInstallBody): +async def local_models_runtime_install(body: RuntimeInstallBody, profile: Optional[str] = None): tag, backend = _runtime_target(body.backend) plan = _resolve_assets_or_400(tag, backend) job = _job("runtime-install", f"llama.cpp {tag} ({backend})") @@ -665,9 +665,13 @@ async def local_models_runtime_install(body: RuntimeInstallBody): def _run(): previous = binaries.installed_tags() _step(job, "downloading", f"Fetching {len(plan.assets)} package(s) for {backend}") - binaries.ensure_runtime_installed(tag, backend, progress=_runtime_progress_hook(job)) - # Restart failure is logged only: the new build is installed either way and the next boot serves it. - restarted = _quiet(lambda: _restart_on_new_tag(job, tag, previous), False, warn="post-update restart skipped: %s") + # The engine binaries are machine-global, but ensure_local_runtime also regenerates the + # launch presets under get_hermes_home() — scope so those land in the named profile. + with _config_profile_scope(profile): + binaries.ensure_runtime_installed(tag, backend, progress=_runtime_progress_hook(job)) + # Restart failure is logged only: the new build is installed either way and the next boot serves it. + restarted = _quiet(lambda: _restart_on_new_tag(job, tag, previous), False, + warn="post-update restart skipped: %s") # N-1 retention, only after the new tag verified: keep it + the newest previous build as the rollback pin target. _quiet(lambda: binaries.prune_old_tags([tag] + [t for t in previous if t != tag][:1]), None, warn="runtime prune skipped: %s") @@ -748,7 +752,7 @@ def _quickstart_target(body: QuickstartBody, budget): @router.post("/api/local-models/quickstart") -async def local_models_quickstart(body: QuickstartBody): +async def local_models_quickstart(body: QuickstartBody, profile: Optional[str] = None): """One job: install the runtime (if missing), download this machine's build of the recommended model (if missing), make it the default. Each leg uses the same code as the individual setup routes. Preflight rejects (no automatic recommendation or no servable choice) fail the POST @@ -775,11 +779,14 @@ async def local_models_quickstart(body: QuickstartBody): job["done_bytes"] = 0 job["total_bytes"] = download_bytes _run_download_plan(job, download_plan, entry.display_name) - # Activate: same sequence as /activate's job body. - _ensure_server(job, _set_runtime_enabled(True), variant.model_id, - fail_detail="The local server could not start — open Local Models for details", - skip_msg="quickstart rescan check skipped") - _assign_default(job, variant.model_id) + # Activate: same sequence as /activate's job body, and the same scope. Quickstart IS + # `activate` plus a download: _set_runtime_enabled and _assign_default both reach + # save_config, so without this the config.yaml write lands in the launch profile. + with _config_profile_scope(profile): + _ensure_server(job, _set_runtime_enabled(True), variant.model_id, + fail_detail="The local server could not start — open Local Models for details", + skip_msg="quickstart rescan check skipped") + _assign_default(job, variant.model_id) _finish(job, f"{entry.display_name} is ready — new chats use it") _spawn_job(job, "lr-quickstart", _run, fail_msg="quickstart failed: %s", on_exit=_QUICKSTART_LOCK.release) diff --git a/hermes_cli/web_routers/models.py b/hermes_cli/web_routers/models.py index 766e001933..e7911dfd7f 100644 --- a/hermes_cli/web_routers/models.py +++ b/hermes_cli/web_routers/models.py @@ -137,7 +137,7 @@ def _nous_recommended_default() -> dict: @router.get("/api/model/recommended-default") -def get_recommended_default_model(provider: str = ""): +def get_recommended_default_model(provider: str = "", profile: Optional[str] = None): """Recommended default model for a freshly-authenticated provider, mirroring ``hermes model``'s curation so GUI onboarding lands on a sensible default. Nous honors the user's free/paid tier. Any other provider gets the preferred @@ -159,7 +159,10 @@ def get_recommended_default_model(provider: str = ""): from hermes_cli.inventory import build_models_payload, load_picker_context from hermes_cli.models import pick_silent_default_model - payload = build_models_payload(load_picker_context()) + # build_models_payload -> list_authenticated_providers -> _save_discovered_models_to_config: + # this GET lazily PERSISTS discovered custom-provider models, so it needs the scope too. + with _config_profile_scope(profile): + payload = build_models_payload(load_picker_context()) for row in payload.get("providers", []): if str(row.get("slug", "")).lower() == slug: models = [str(m) for m in (row.get("models") or [])] diff --git a/hermes_cli/web_routers/ops.py b/hermes_cli/web_routers/ops.py index c935e1c719..838ab68027 100644 --- a/hermes_cli/web_routers/ops.py +++ b/hermes_cli/web_routers/ops.py @@ -499,8 +499,12 @@ async def set_memory_provider(body: MemoryProviderSelect, profile: Optional[str] provider = _normalize_memory_provider_name(body.provider) def _run(): - _require_memory_provider_ready(provider) + # Readiness resolves through load_config()/_discover_memory_provider_statuses(), so it + # MUST run inside the scope: outside it a provider configured only in the target profile + # reads as "not ready" (refused) and one configured only in the launch profile reads as + # ready and gets written into the target as a broken setting. with config_write_scope(profile): + _require_memory_provider_ready(provider) cfg = load_config() if not isinstance(cfg.get("memory"), dict): cfg["memory"] = {} @@ -708,6 +712,11 @@ async def create_hook(body: HookCreate, profile: Optional[str] = None): """ from agent import shell_hooks + # Creating an auto-approved shell hook is strictly more privileged than removing one: + # it writes an arbitrary command into `hooks:` and, with `approve`, into that profile's + # consent allowlist. Same rule as DELETE — an unnamed profile is refused while several + # are served rather than silently arming the launch profile. + profile = destructive_profile(profile, "POST /api/ops/hooks") event, command = _hook_body_fields(body) valid_hooks = None with contextlib.suppress(Exception): diff --git a/hermes_cli/web_routers/sessions.py b/hermes_cli/web_routers/sessions.py index a89097ee0a..0eba929e57 100644 --- a/hermes_cli/web_routers/sessions.py +++ b/hermes_cli/web_routers/sessions.py @@ -22,7 +22,7 @@ from hermes_cli.web_server_gateway import _strip_session_list_rows from hermes_cli.web_server_sessions import _maybe_auto_archive_for_profile, _session_latest_descendant from hermes_cli.web_models import ( BulkDeleteSessions, SessionImport, SessionOwnerBackfill, SessionPrune, SessionRename) -from hermes_cli.web_routers._common import log as _log, http_failure +from hermes_cli.web_routers._common import log as _log, destructive_profile, http_failure from hermes_state import is_malformed_db_error from hermes_state_errors import is_transient_sqlite_error @@ -409,8 +409,9 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): # Hard cap so a runaway selection can't lock the writer for long. if len(body.ids) > 500: raise HTTPException(status_code=400, detail="ids must contain at most 500 entries") + profile = destructive_profile(body.profile, "POST /api/sessions/bulk-delete") deleted = await asyncio.to_thread( - _with_db, body.profile, lambda db: db.delete_sessions(body.ids), read_only=False) + _with_db, profile, lambda db: db.delete_sessions(body.ids), read_only=False) return {"ok": True, "deleted": deleted} @@ -458,7 +459,8 @@ async def delete_empty_sessions_endpoint(profile: Optional[str] = None): parents are orphaned, not cascade-deleted. See #95868. """ deleted = await asyncio.to_thread( - _with_db, profile, lambda db: db.delete_empty_sessions(), read_only=False) + _with_db, destructive_profile(profile, "DELETE /api/sessions/empty"), + lambda db: db.delete_empty_sessions(), read_only=False) return {"ok": True, "deleted": deleted} @@ -760,6 +762,11 @@ async def export_session_endpoint(session_id: str, profile: Optional[str] = None @manage_router.post("/api/sessions/prune") async def prune_sessions_endpoint(body: SessionPrune): """Delete ended sessions matching filters without blocking the event loop.""" + if not body.dry_run: + # Same destructive rule as the rest of the family; a dry run deletes nothing, so it + # keeps working unnamed (it is the preview the confirm dialog reads). + body = body.model_copy(update={ + "profile": destructive_profile(body.profile, "POST /api/sessions/prune")}) return await asyncio.to_thread(_prune_sessions, body) diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index dddfd13317..8363a7d9db 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -156,12 +156,19 @@ def mount_spa(application: FastAPI): # Launcher-preselected profile (``--open-profile``): the SPA's fallback scope when the URL # omits ``?profile=`` (#73085). ``{token_js}" f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};" f'window.__HERMES_BASE_PATH__="{prefix}";' f"window.__HERMES_AUTH_REQUIRED__={'true' if gated else 'false'};" f"window.__HERMES_INITIAL_PROFILE__={initial_profile_js};" + f"window.__HERMES_DASHBOARD_PROFILE__={serving_profile_js};" f"" ) if prefix: diff --git a/hermes_cli/web_server_profiles.py b/hermes_cli/web_server_profiles.py index 362b162ec5..fed42466a1 100644 --- a/hermes_cli/web_server_profiles.py +++ b/hermes_cli/web_server_profiles.py @@ -46,6 +46,28 @@ def _hermes_home_scope(path) -> Any: reset_hermes_home_override(token) +def serving_profile_name() -> str: + """This process's OWN profile name — but only when that name provably resolves back + to the process home. + + The dashboard SPA needs an explicit scope for requests it fires before the profile + switcher has resolved: a destructive route now 400s on an unnamed profile as soon as + the host serves more than one, and "" would otherwise mean "whichever home this + process launched with" anyway. Naming it is only safe if the name cannot resolve + ELSEWHERE, so a custom HERMES_HOME outside ``profiles/`` (``get_active_profile_name()`` + answers ``"custom"``) returns "" and keeps the old unnamed behaviour rather than + risking a wrong-profile write. + """ + from hermes_cli import profiles as profiles_mod + try: + name = (profiles_mod.get_active_profile_name() or "").strip() + if not name or name == "custom": + return "" + return name if profiles_mod.profile_matches_home(name, get_process_hermes_home()) else "" + except Exception: + return "" + + def _is_other_profile(profile: Optional[str]) -> bool: """True when ``profile`` names a profile other than this process's own.""" if _is_current_profile(profile): diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c50810ed0a..16fb2f0493 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -4,6 +4,8 @@ import { type ModelOptionsResult, } from "@hermes/shared"; +import { dashboardServingProfile } from "./profile-bootstrap"; + // The dashboard can be served either at the root of its host (e.g. // https://kanban.tilos.com/) or under a URL prefix when reverse-proxied // (e.g. https://mission-control.tilos.com/hermes/). The Python backend @@ -64,8 +66,18 @@ export function setManagementProfile(name: string): void { _managementProfile = (name || "").trim(); } +/** + * The profile every management call targets: the switcher's selection, or — + * before it has resolved / on a host with no switcher interaction — the profile + * this backend itself serves. + * + * The fallback is not a guess: the backend injects a name only when it provably + * resolves back to its own home, so it names exactly the home an unnamed request + * already reached. Without it the dashboard sends no `?profile=` at all and every + * destructive route 400s as soon as the host has a second profile directory. + */ export function getManagementProfile(): string { - return _managementProfile; + return _managementProfile || dashboardServingProfile(); } // Endpoint families that honor ?profile= on the backend (web_server.py @@ -108,18 +120,33 @@ const PROFILE_SCOPED_PREFIXES = [ "/api/ops", "/api/logs", "/api/portal", + // Pool entries live in the profile's home, and DELETE /api/credentials/pool/{provider}/{index} + // is destructive — without this prefix the dashboard's remove button never named a profile and + // a multi-profile host refused it outright. + "/api/credentials", + // Not covered by "/api/dashboard/plugins": this one writes memory.provider + context.engine + // into the named profile's config.yaml (same key as PUT /api/memory/provider). + "/api/dashboard/plugin-providers", + // Model/runtime activation persists into config.yaml; the read routes ignore an extra param. + "/api/model/recommended-default", + "/api/local-models", "/api/dashboard/theme", "/api/dashboard/font", "/api/dashboard/plugins", ]; +// The dashboard's own profile when nothing else named one. The backend injects it only +// when it provably resolves back to the serving home, so this can never retarget another +// profile — it just says out loud what an unnamed request already meant. Without it every +// destructive route 400s on a host that merely HAS a second profile directory. function withManagementProfile(url: string): string { - if (!_managementProfile) return url; + const scope = getManagementProfile(); + if (!scope) 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)}`; + return `${url}${sep}profile=${encodeURIComponent(scope)}`; } export async function fetchJSON( @@ -281,6 +308,11 @@ export async function authedFetch( url: string, init?: RequestInit, ): Promise { + // Same management scope as fetchJSON: a binary endpoint under a profile-scoped + // family (``/api/ops/backup/download``) must read the SELECTED profile's archive, + // not the launch profile's, and an unprofiled back door beside a family that now + // 400s is exactly how the next hole gets in. + url = withManagementProfile(url); const headers = new Headers(init?.headers); const token = window.__HERMES_SESSION_TOKEN__; if (token) { diff --git a/web/src/lib/profile-bootstrap.ts b/web/src/lib/profile-bootstrap.ts index 8a0cb2204c..51402fb1c8 100644 --- a/web/src/lib/profile-bootstrap.ts +++ b/web/src/lib/profile-bootstrap.ts @@ -1,5 +1,6 @@ declare global { interface Window { + __HERMES_DASHBOARD_PROFILE__?: string; __HERMES_INITIAL_PROFILE__?: string; } } @@ -9,12 +10,29 @@ export function dashboardInitialProfile(): string { return window.__HERMES_INITIAL_PROFILE__ ?? ""; } +/** + * The profile this backend process itself serves, injected by the server and + * empty when it cannot be named unambiguously (custom HERMES_HOME). + * + * It is the LAST fallback for the management scope: without it the dashboard + * sends no `?profile=` at all, which a multi-profile host now refuses (400) on + * every destructive route. It is never a guess — the backend only emits a name + * that provably resolves back to its own home, so it targets exactly the home + * an unnamed request used to reach. + */ +export function dashboardServingProfile(): string { + if (typeof window === "undefined") return ""; + return window.__HERMES_DASHBOARD_PROFILE__ ?? ""; +} + export function initialProfileScope( searchParams: URLSearchParams, bootstrapProfile = dashboardInitialProfile(), + servingProfile = dashboardServingProfile(), ): string { const urlProfile = searchParams.get("profile"); - return urlProfile === null ? bootstrapProfile : urlProfile; + if (urlProfile !== null) return urlProfile; + return bootstrapProfile || servingProfile; } export function shouldAdoptActiveProfile(