fix(profiles): freeze the launch home once the process serves several profiles
Every "does this task serve a ROUTED home" decision (serves_routed_profile, _is_process_home, _is_routed_home, env_loader._process_hermes_home) compares the home override with get_process_hermes_home(), which read os.environ["HERMES_HOME"] live. A host that mirrors the served profile into that env var per turn (hermes-webui) made every served profile look like the launch one: MCP registry scope None, bare cross-profile connection names, launch residue kept in served child envs, the launch GATEWAY_ALLOW_ALL_USERS grant seeded into the served scope. set_multiplex_active(True) now pins the launch home (hermes_constants. pin_process_hermes_home; first pin wins, an embedding host may pin explicitly) and get_process_hermes_home() returns the frozen value while multiplex is active. Standalone hermes -p x gateway run (multiplex inactive) keeps following the env. No os.environ fallthrough is added anywhere. Closes #119242
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
66
tests/agent/test_secret_scope_pinned_launch_home.py
Normal file
66
tests/agent/test_secret_scope_pinned_launch_home.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user