From c7c9c18ccf0e6d2199bc5b0614bffb8f8d763c5a Mon Sep 17 00:00:00 2001 From: tancou Date: Tue, 22 Sep 2026 12:13:41 +0200 Subject: [PATCH] fix(profiles): pin the launch home so a mirrored HERMES_HOME cannot flip routed-profile decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: a host that serves several profiles from one process and mirrors the active turn's profile into `os.environ["HERMES_HOME"]` for legacy readers (Hermes WebUI does this on every chat turn, next to the context-local override) makes every launch-home decision see the served profile as the launch profile. Two profiles that both configure `atlassian` with different credentials share whichever MCP connection came first: a READ_ONLY_MODE=false profile ends up calling a read-only server (nesquena/hermes-webui#7721). The same misjudgement leaves the launch residue in the served profile's child env, seeds the launch profile's bridged allow-all grant into the served profile's secret scope, and lets the served profile's `terminal.*` config bridge into the shared process env. Cause: four launch-home checks compare the task's override with `get_process_hermes_home()`, which reads `HERMES_HOME` live: `agent.secret_scope.serves_routed_profile` (keys the MCP ledger via `_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151), `agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home` and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two sides are equal for every turn. Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the host record the home it serves as its own; `get_routing_process_hermes_home()` returns the pin when set, else `get_process_hermes_home()`; the four checks compare against it. The pin is deliberately NOT folded into `get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks carrying no override (MCP loop, spawners), and the host's mirror exists precisely so those readers see the served profile. Only "is this task routed / is this the launch home" changes. Unpinned, behaviour is byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not call it. `activate_multi_profile_hosting()` is not the seam for this: it flips `get_secret` fail-closed process-wide and freezes the launch env, which an embedding host cannot adopt as a bug fix. Tests (2 invariants, parametrized over the four checks plus the MCP ledger key; red on main, green here): pinned + mirrored env -> the served home is routed and the launch home is not, the MCP key is `(home_key, name)`, `get_process_hermes_home()` still follows the env var; never pinned or pinned-then-cleared -> old semantics, including "a mirrored env var IS the launch home". `tests/conftest.py` resets the pin per test so the module-global cannot leak between files. Live repro (WebUI + a stdio FastMCP server named `atlassian` in two profiles, one gated by READ_ONLY_MODE): base -> one ledger key `'atlassian'`, the write profile lists only the read-only tools; fixed -> `(, 'atlassian')` and `(, 'atlassian')`, each profile lists its own tools. Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the isolation table in `website/docs/user-guide/multi-profile-gateways.md`. Also maps the author e-mail under contributors/emails/ (attribution check). Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Claude Fable 5.1 --- agent/secret_scope.py | 15 ++-- contributors/emails/git.commits@tancou.me | 2 + gateway/AGENTS.md | 8 ++ hermes_cli/env_loader.py | 11 ++- hermes_constants.py | 48 ++++++----- tests/agent/test_serves_routed_profile_pin.py | 82 +++++++++++++++++++ tests/conftest.py | 7 ++ tools/environments/local.py | 10 ++- .../docs/user-guide/multi-profile-gateways.md | 1 + 9 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 contributors/emails/git.commits@tancou.me create mode 100644 tests/agent/test_serves_routed_profile_pin.py diff --git a/agent/secret_scope.py b/agent/secret_scope.py index cc414f9c3f..1860334c53 100644 --- a/agent/secret_scope.py +++ b/agent/secret_scope.py @@ -62,11 +62,13 @@ def serves_routed_profile() -> bool: multiplexing, else when a HERMES_HOME override names another home (dashboard/desktop backend, per-profile cron ticker) or a secret scope stamped with a foreign home is bound. The MCP registry scope and the check_fn cache key both follow this predicate so a served profile's - view never aliases the launch profile's (#111151).""" + view never aliases the launch profile's (#111151). A host that mirrors the turn's profile into + ``HERMES_HOME`` pins its own home with ``hermes_constants.pin_process_hermes_home`` so the + mirror cannot flip this predicate.""" if is_multiplex_active(): return True - from hermes_constants import get_hermes_home_override, get_process_hermes_home, hermes_home_key - own = hermes_home_key(get_process_hermes_home()) + from hermes_constants import get_hermes_home_override, get_routing_process_hermes_home, hermes_home_key + own = hermes_home_key(get_routing_process_hermes_home()) bound = _SECRET_SCOPE.get() if bound is not None and bound.profile_home and hermes_home_key(bound.profile_home) != own: return True @@ -375,8 +377,11 @@ def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]: def _is_process_home(hermes_home: Path) -> bool: - from hermes_constants import get_process_hermes_home + """Is *hermes_home* the profile this process serves as its own? Same launch-home identity as + ``serves_routed_profile()``: a host that mirrors a served profile into ``HERMES_HOME`` would + otherwise seed the launch profile's bridged allow-all grant into that profile's scope.""" + from hermes_constants import get_routing_process_hermes_home try: - return Path(hermes_home).resolve() == get_process_hermes_home().resolve() + return Path(hermes_home).resolve() == get_routing_process_hermes_home().resolve() except OSError: return False diff --git a/contributors/emails/git.commits@tancou.me b/contributors/emails/git.commits@tancou.me new file mode 100644 index 0000000000..4be606ece7 --- /dev/null +++ b/contributors/emails/git.commits@tancou.me @@ -0,0 +1,2 @@ +tancou +# PR #119129 (pin process home for routed-profile detection) diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index a5552a47c9..f08acafdfe 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -238,6 +238,14 @@ gateway under the backend, and do NOT "fix" update locks by widening the tree-ki the default profile only; a secondary enabling one is logged once with the remedy and stamped into runtime status (`run_adapters.py::_note_unserved_secondary_platform`). `needs_attention` is set and cleared at the single writer (`_update_platform_runtime_status`) on the connect path. +- **One launch-home identity.** "Does this task serve a routed profile?" compares the override + with `hermes_constants.get_routing_process_hermes_home()` (`agent/secret_scope.py:: + serves_routed_profile` and `_is_process_home`, `tools/environments/local.py::_is_routed_home`, + `hermes_cli/env_loader.py::_process_hermes_home`), never with `os.environ["HERMES_HOME"]` read + live: an embedding host that mirrors the served profile into the env var per turn (Hermes + WebUI) pins its own home with `pin_process_hermes_home()`, and without a pin the resolver is + `get_process_hermes_home()` unchanged. Do not add another routing decision that compares + against `get_process_hermes_home()` directly; that resolver is for process-level assets. ## Tests diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 0026f14819..aa5c398d81 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -660,12 +660,15 @@ def _process_hermes_home() -> Path: (mtime,size)-keyed config cache is safe to reuse; under an override it must fall through to an isolated parse of the scoped profile. - ``hermes_constants.get_process_hermes_home()`` is the override-immune - resolver built for exactly this; delegate to it. + ``hermes_constants.get_routing_process_hermes_home()`` is the override-immune + resolver built for exactly this; delegate to it. It is also immune to a host + that mirrors the served profile into the live ``HERMES_HOME`` env var + (``pin_process_hermes_home``): without that, the mirrored profile satisfied + the guard above and bridged ITS ``terminal.*`` into the shared env. """ try: - from hermes_constants import get_process_hermes_home + from hermes_constants import get_routing_process_hermes_home - return get_process_hermes_home() + return get_routing_process_hermes_home() except Exception: return Path.home() / ".hermes" diff --git a/hermes_constants.py b/hermes_constants.py index 40b0bdba5a..cd30be4afa 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -159,37 +159,41 @@ def get_process_hermes_home() -> Path: """Hermes home of the running process, ignoring task overrides. For process-level assets (theme YAML, dashboard plugin manifests) that must stay visible while a - request is scoped to another profile (e.g. embedded ``/chat`` under ``--open-profile``), and the - reference every "does this task serve a ROUTED home" decision compares the override against. - Once pinned (``pin_process_hermes_home``) the answer is frozen: a host that mirrors the served - profile into ``os.environ["HERMES_HOME"]`` per turn would otherwise re-label the launch home on - every turn and every served profile would look like the launch one (#119242). + request is scoped to another profile (e.g. embedded ``/chat`` under ``--open-profile``). Follows + ``HERMES_HOME`` live on purpose: routed-profile DECISIONS compare against + :func:`get_routing_process_hermes_home` instead (#119242). """ - if _PINNED_PROCESS_HOME is not None: - return _PINNED_PROCESS_HOME val = os.environ.get("HERMES_HOME", "").strip() return _expand_hermes_home(val) if val else _get_platform_default_hermes_home() -# The launch home, frozen the moment this process starts serving a second profile -# (``agent.secret_scope.set_multiplex_active(True)``) or when an embedding host pins it explicitly. -_PINNED_PROCESS_HOME: Path | None = None +# Host-pinned identity of the profile this process serves as its own (None: follow HERMES_HOME). +_PINNED_PROCESS_HERMES_HOME: str | None = None -def pin_process_hermes_home(path: "str | Path | None" = None) -> Path: - """Freeze the launch home; the first pin wins. ``None`` pins the home the process env names NOW, - so it must run before any per-turn mirror of ``HERMES_HOME`` — the same moment - ``tui_gateway.launch_profile_policy.capture_launch_env`` freezes the env.""" - global _PINNED_PROCESS_HOME - if _PINNED_PROCESS_HOME is None: - _PINNED_PROCESS_HOME = _expand_hermes_home(str(path)) if path else get_process_hermes_home() - return _PINNED_PROCESS_HOME +def pin_process_hermes_home(path: str | Path | None) -> None: + """Pin the home this process serves as its own profile, for "is this task routed?" decisions. + + An embedding host that serves several profiles and mirrors the active turn's profile into + ``os.environ["HERMES_HOME"]`` for legacy readers (Hermes WebUI) otherwise makes every turn's own + profile look like the launch profile: ``agent.secret_scope.serves_routed_profile()`` turns + False and that turn's MCP connections fall back to bare, cross-profile names; the sibling + launch-home checks (``secret_scope._is_process_home``, ``tools.environments.local._is_routed_home``, + ``hermes_cli.env_loader._process_hermes_home``) misjudge the same way. ``None`` clears the pin. + + Process-global on purpose: it names the process's own identity, not a per-task value. It is NOT + folded into :func:`get_process_hermes_home`: :func:`get_hermes_home` falls back to that for + tasks carrying no override, and the host's mirror exists precisely so those readers see the + served profile. Hosts that never mutate ``HERMES_HOME`` need not call this (no-op). + """ + global _PINNED_PROCESS_HERMES_HOME + _PINNED_PROCESS_HERMES_HOME = None if path is None else str(path) -def unpin_process_hermes_home() -> None: - """Follow ``HERMES_HOME`` live again (single-profile mode; tests that stand hosts up and down).""" - global _PINNED_PROCESS_HOME - _PINNED_PROCESS_HOME = None +def get_routing_process_hermes_home() -> Path: + """Launch home for routed-profile decisions: the pinned home, else :func:`get_process_hermes_home`.""" + pinned = _PINNED_PROCESS_HERMES_HOME + return _expand_hermes_home(pinned) if pinned else get_process_hermes_home() # Hermes-managed runtime downloads at the root of a home (GGUF models, llama.cpp runtimes, diff --git a/tests/agent/test_serves_routed_profile_pin.py b/tests/agent/test_serves_routed_profile_pin.py new file mode 100644 index 0000000000..58c2cd8b53 --- /dev/null +++ b/tests/agent/test_serves_routed_profile_pin.py @@ -0,0 +1,82 @@ +"""A host that mirrors the served profile into HERMES_HOME must not flip launch-home identity. + +Hermes WebUI serves several profiles from one process and, for legacy readers, mirrors the active +turn's profile into ``os.environ["HERMES_HOME"]`` while also installing the context-local override. +Every "is this task routed / is this the launch home" decision that compared the override with the +live env var then saw the served home as the launch home: MCP connections were keyed by bare name +(shared across profiles), the launch residue was never stripped from the served profile's child env, +the launch profile's bridged allow-all grant was seeded into the served profile's secret scope, and +the served profile's ``terminal.*`` config was bridged into the shared process env. +``hermes_constants.pin_process_hermes_home`` gives such hosts one stable anchor for all of them. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +import hermes_constants +from agent.secret_scope import _is_process_home, serves_routed_profile +from hermes_cli.env_loader import _process_hermes_home +from tools.environments.local import _is_routed_home +from tools.mcp_tool_scope import _server_key + + +def _under(home, fn): + """Run *fn* with *home* installed as the task's Hermes-home override.""" + token = hermes_constants.set_hermes_home_override(home) + try: + return fn() + finally: + hermes_constants.reset_hermes_home_override(token) + + +# name -> "does this decision treat *home* as a routed (non-launch) profile?" +ROUTED = { + "secret_scope.serves_routed_profile": lambda home: _under(home, serves_routed_profile), + "secret_scope._is_process_home": lambda home: not _is_process_home(home), + "environments.local._is_routed_home": lambda home: _is_routed_home(home), + "env_loader._process_hermes_home": lambda home: _process_hermes_home().resolve() != Path(home).resolve(), + "mcp_tool_scope._server_key": lambda home: _under(home, lambda: _server_key("atlassian")) != "atlassian", +} + + +@pytest.fixture +def homes(tmp_path, monkeypatch): + launch = tmp_path / "launch" + served = tmp_path / "profiles" / "served" + launch.mkdir() + served.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME", None) + return launch, served + + +@pytest.mark.parametrize("decision", sorted(ROUTED)) +def test_pinned_launch_home_survives_a_mirrored_hermes_home(homes, monkeypatch, decision): + launch, served = homes + routed = ROUTED[decision] + hermes_constants.pin_process_hermes_home(launch) + monkeypatch.setenv("HERMES_HOME", str(served)) # the host's per-turn mirror + + assert routed(served) is True + assert routed(launch) is False + # The served profile's MCP connection is its own, keyed by its home, not a bare shared name. + assert _under(served, lambda: _server_key("atlassian")) == (hermes_constants.hermes_home_key(served), "atlassian") + # Process-asset readers keep following the env var: only routing decisions use the pin. + assert hermes_constants.get_process_hermes_home() == served + assert hermes_constants.get_routing_process_hermes_home() == launch + + +@pytest.mark.parametrize("decision", sorted(ROUTED)) +def test_unpinned_or_cleared_pin_keeps_hermes_home_semantics(homes, monkeypatch, decision): + launch, served = homes + routed = ROUTED[decision] + for _ in ("never pinned", "pinned then cleared"): + monkeypatch.setenv("HERMES_HOME", str(launch)) + assert routed(served) is True + assert routed(launch) is False + monkeypatch.setenv("HERMES_HOME", str(served)) # mirrored: the env var IS the launch home + assert routed(served) is False + hermes_constants.pin_process_hermes_home(launch) + hermes_constants.pin_process_hermes_home(None) diff --git a/tests/conftest.py b/tests/conftest.py index c3db9a4265..33f4648bff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -531,6 +531,13 @@ def _hermetic_environment(tmp_path, monkeypatch): (fake_hermes_home / "memories").mkdir() (fake_hermes_home / "skills").mkdir() monkeypatch.setenv("HERMES_HOME", str(fake_hermes_home)) + # A test that pins the process home (hermes_constants.pin_process_hermes_home) must not + # leak that module-global into the next test's routed-profile decisions. + try: + import hermes_constants as _hc + monkeypatch.setattr(_hc, "_PINNED_PROCESS_HERMES_HOME", None, raising=False) + except Exception: + pass # Per-TEST host-rendezvous dir (see the session-level block at the top): the # host gateway/serve record is shared per OS user by design, so without this # one test's published owner makes the next test's lifecycle code attach to it. diff --git a/tools/environments/local.py b/tools/environments/local.py index 7008bab780..fb0ea98835 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -410,10 +410,14 @@ def served_profile_child_env( def _is_routed_home(target_home: "str | Path") -> bool: - """True when ``target_home`` is not the process's own (launch) home.""" - from hermes_constants import get_process_hermes_home + """True when ``target_home`` is not the process's own (launch) home. + + Same launch-home identity as ``agent.secret_scope.serves_routed_profile()``: under a host that + mirrors the served profile into ``HERMES_HOME``, the live env var names the served home and the + launch residue would never be stripped from that profile's child env.""" + from hermes_constants import get_routing_process_hermes_home try: - return Path(target_home).resolve() != get_process_hermes_home().resolve() + return Path(target_home).resolve() != get_routing_process_hermes_home().resolve() except OSError: return True diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 47d2c75c69..46304dc14a 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -621,6 +621,7 @@ profile and never shares with the default or any sibling: | Dashboard actions (`hermes -p …` spawned by the Desktop/dashboard) | A scrubbed child env pinned to that profile's `HERMES_HOME` | The child loads its own `.env`; the dashboard profile's tokens and ports are not inherited | | Every child that acts for a served profile (slash worker, Bot Chat delivery, A2A forward, `key_cmd` helper, browser driver) | That profile's own `.env` + secret sources over a credential-scrubbed base — with or without `gateway.multiplex_profiles` (the Desktop/dashboard `?profile=` route counts) | Absent from the child — a key that reached the launch process only through systemd / Compose / the shell is never inherited by another profile's child | | Authorization gates in a child spawned for another profile (`*_ALLOWED_USERS` / `*_ALLOWED_CHANNELS` / `*_IGNORED_CHANNELS` / `*_ALLOW_ALL_USERS` / `*_ALLOW_BOTS`, `GATEWAY_ALLOW*`) — dashboard `hermes -p ` actions, kanban workers, Bot Chat delivery, the post-update per-profile `gateway restart` | The child's own `.env` / `config.yaml`, loaded by the child itself | Closed (the adapter's documented default) — a gate exported into the spawning process by a unit file or the shell is dropped before the child starts, so profile B never enforces profile A's channel or user list; a same-profile child keeps it | +| Routed-profile detection in an embedding host that mirrors the served profile into the live `HERMES_HOME` env var for legacy readers (Hermes WebUI) | The launch home the host pinned with `hermes_constants.pin_process_hermes_home()`; MCP connection keys, the launch-env strip for a served profile's children, the bridged allow-all seed and the `terminal.*` env-bridge guard all compare against it | Without a pin the live env var is the launch home, exactly as before — a host that never mutates `HERMES_HOME` needs nothing | | The launch (default) profile's own credentials in a `hermes serve` / dashboard process that also serves another profile | Its `.env` + secret sources over the process env **frozen the moment the first other profile is served**; not re-read afterwards | A credential rotated only in the process env (`systemctl set-environment`, a refreshed `op run` wrapper that did not re-exec) is not picked up until the process restarts — put rotating keys in `.env` or a secret source, or restart after rotating | | Cron `.env` tuning (`HERMES_CRON_TIMEOUT`, `HERMES_MODEL` fallback, `HERMES_CRON_MAX_PARALLEL`, prefill file), worker / Bot Chat child env | The profile's own `.env`; children never inherit the default profile's `.env` settings or bridged `TERMINAL_*` policy | Cron defaults / model refusal, exactly as a standalone `hermes -p gateway run` | | Kanban workers and notifications for a profile's tasks | The assignee's `.env` + `config.yaml` (toolset pin, terminal backend, media policy, display language) | — |