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:
teknium1
2026-09-23 03:17:20 -07:00
committed by Teknium
parent 6dc6fb593a
commit 8932450779
4 changed files with 107 additions and 2 deletions

View File

@@ -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:

View File

@@ -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

View 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

View File

@@ -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)