diff --git a/agent/secret_scope.py b/agent/secret_scope.py index 2dd0728ebe..cc414f9c3f 100644 --- a/agent/secret_scope.py +++ b/agent/secret_scope.py @@ -29,9 +29,18 @@ _MULTIPLEX_ACTIVE: bool = False def set_multiplex_active(active: bool) -> None: - """Mark whether the process is a profile multiplexer (get_secret fails closed).""" + """Mark whether the process is a profile multiplexer (get_secret fails closed). + + Activation also freezes the launch home (``hermes_constants.pin_process_hermes_home``): from + here on "is this task routed" compares the override against the home the process was launched + with, not against whatever a host later mirrors into ``os.environ["HERMES_HOME"]``.""" global _MULTIPLEX_ACTIVE + from hermes_constants import pin_process_hermes_home, unpin_process_hermes_home _MULTIPLEX_ACTIVE = bool(active) + if _MULTIPLEX_ACTIVE: + pin_process_hermes_home() + else: + unpin_process_hermes_home() def is_multiplex_active() -> bool: diff --git a/hermes_constants.py b/hermes_constants.py index bec5d17d96..40b0bdba5a 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -159,12 +159,39 @@ 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``). + 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). """ + 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 + + +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 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 + + # Hermes-managed runtime downloads at the root of a home (GGUF models, llama.cpp runtimes, # managed Node): re-downloadable on demand and routinely tens to hundreds of GB. Shared by # ``hermes backup`` (excludes them) and ``profile create --clone-all`` (skips them from the diff --git a/tests/agent/test_secret_scope_pinned_launch_home.py b/tests/agent/test_secret_scope_pinned_launch_home.py new file mode 100644 index 0000000000..32f580ee51 --- /dev/null +++ b/tests/agent/test_secret_scope_pinned_launch_home.py @@ -0,0 +1,66 @@ +"""The launch home is frozen once the process serves several profiles (#119242). + +Every "does this task serve a ROUTED home" decision (``agent.secret_scope.serves_routed_profile``, +``_is_process_home``, ``tools.environments.local._is_routed_home``, +``hermes_cli.env_loader._process_hermes_home``) compares the task's home override with +``get_process_hermes_home()``. That used to read ``os.environ["HERMES_HOME"]`` live, so a host that +mirrors the served profile into the env on every turn (Hermes WebUI does) made every served +profile look like the launch one: MCP connections fell back to bare cross-profile names, the launch +residue survived ``strip_launch_profile_env``, the launch profile's bridged grants seeded the +served scope. ``set_multiplex_active(True)`` now pins the launch home; a later env mutation cannot +re-label it. Standalone ``hermes -p x gateway run`` (multiplex inactive) keeps following the env. +""" +from __future__ import annotations + +import pytest + +import hermes_constants +from agent.secret_scope import _is_process_home, serves_routed_profile, set_multiplex_active +from hermes_cli.env_loader import _process_hermes_home +from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override +from tools.environments.local import _is_routed_home + + +@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_HOME", None, raising=False) + return launch, served + + +def test_a_per_turn_env_mirror_cannot_relabel_the_launch_home_under_multiplex(homes, monkeypatch): + launch, served = homes + set_multiplex_active(True) + monkeypatch.setenv("HERMES_HOME", str(served)) # the host's per-turn mirror + token = set_hermes_home_override(served) + try: + assert get_process_hermes_home() == launch + assert _process_hermes_home() == launch + assert _is_routed_home(served) and not _is_routed_home(launch) + assert not _is_process_home(served) and _is_process_home(launch) + assert serves_routed_profile() + finally: + reset_hermes_home_override(token) + set_multiplex_active(False) + # Pin released with the mode: the env is authoritative again. + assert get_process_hermes_home() == served + + +def test_a_standalone_profile_process_keeps_following_its_env(homes, monkeypatch): + """T1 (``hermes -p x gateway run``): multiplex inactive, the env IS the profile — an override + naming that same home is not routed, and a later env change is followed.""" + launch, served = homes + monkeypatch.setenv("HERMES_HOME", str(served)) + token = set_hermes_home_override(served) + try: + assert get_process_hermes_home() == served + assert not _is_routed_home(served) + assert not serves_routed_profile() + finally: + reset_hermes_home_override(token) + monkeypatch.setenv("HERMES_HOME", str(launch)) + assert get_process_hermes_home() == launch diff --git a/tests/conftest.py b/tests/conftest.py index 40a65085bb..c3db9a4265 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -565,6 +565,9 @@ def _hermetic_environment(tmp_path, monkeypatch): secret_scope_mod = sys.modules.get("agent.secret_scope") if secret_scope_mod is not None and hasattr(secret_scope_mod, "_MULTIPLEX_ACTIVE"): monkeypatch.setattr(secret_scope_mod, "_MULTIPLEX_ACTIVE", False) + hermes_constants_mod = sys.modules.get("hermes_constants") + if hermes_constants_mod is not None and hasattr(hermes_constants_mod, "_PINNED_PROCESS_HOME"): + monkeypatch.setattr(hermes_constants_mod, "_PINNED_PROCESS_HOME", None) launch_policy_mod = sys.modules.get("tui_gateway.launch_profile_policy") if launch_policy_mod is not None and hasattr(launch_policy_mod, "_snapshot"): monkeypatch.setattr(launch_policy_mod, "_snapshot", None)