From bd970b0588bb663dde5569b3e60eeb0e15541e21 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:12:35 -0700 Subject: [PATCH] fix(profiles): route-only launch pin; profile delete names its own unit under multiplex Builds on tancou's #119129 (cherry-picked above): the pin now lives in get_routing_process_hermes_home() and only the four routed-profile DECISIONS read it. get_process_hermes_home()/get_hermes_home() keep following HERMES_HOME, so an env-only home switch in a multiplexed process resolves as before. - set_multiplex_active(True) pins the launch home only when no host pin exists, and set_multiplex_active(False) releases only the pin it created itself. A transient toggle (gateway_migrate._multiplex_read_mode, cron external-worker restore) no longer drops an embedding host's explicit pin_process_hermes_home(launch). - profiles._cleanup_gateway_service binds set_hermes_home_override(profile_dir) beside the env write. Under the previous head, DELETE /api/profiles/ from a multi-profile dashboard resolved get_service_name() against the pinned launch home -> bare `hermes-gateway`, and disabled/stopped/unlinked the HOST multiplexer's unit. Same path serves rename_profile. Tests (red on the previous head): explicit pin survives True->False; env readers follow the env while pinned; two-home delete removes hermes-gateway-victim and leaves hermes-gateway. --- agent/secret_scope.py | 30 +++++-- hermes_cli/profiles.py | 7 +- hermes_constants.py | 4 + .../test_secret_scope_pinned_launch_home.py | 80 ++++++++++--------- tests/conftest.py | 5 +- .../test_profile_delete_service_name.py | 51 ++++++++++++ 6 files changed, 127 insertions(+), 50 deletions(-) create mode 100644 tests/hermes_cli/test_profile_delete_service_name.py diff --git a/agent/secret_scope.py b/agent/secret_scope.py index 1860334c53..ca7f9357c1 100644 --- a/agent/secret_scope.py +++ b/agent/secret_scope.py @@ -26,21 +26,35 @@ from utils import file_signature # Process-global (describes the deployment mode, not a per-task value): set once # at gateway startup when gateway.multiplex_profiles is true. _MULTIPLEX_ACTIVE: bool = False +# Launch home pinned by set_multiplex_active(True) itself (None: no auto-pin outstanding). +_AUTO_PINNED_HOME = None def set_multiplex_active(active: bool) -> None: """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 + Activation also pins the launch home for routed-profile decisions + (``hermes_constants.pin_process_hermes_home``) unless an embedding host already pinned one: + 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"]``. + Deactivation releases only the pin activation itself created — a transient toggle + (``gateway_migrate._multiplex_read_mode``, a cron worker restoring the caller's mode) must not + drop the host's explicit pin (#119242).""" + global _MULTIPLEX_ACTIVE, _AUTO_PINNED_HOME + from hermes_constants import ( + get_routing_process_hermes_home, + pin_process_hermes_home, + process_hermes_home_is_pinned, + ) _MULTIPLEX_ACTIVE = bool(active) if _MULTIPLEX_ACTIVE: - pin_process_hermes_home() - else: - unpin_process_hermes_home() + if not process_hermes_home_is_pinned(): + _AUTO_PINNED_HOME = get_routing_process_hermes_home() + pin_process_hermes_home(_AUTO_PINNED_HOME) + elif _AUTO_PINNED_HOME is not None: + if get_routing_process_hermes_home() == _AUTO_PINNED_HOME: + pin_process_hermes_home(None) + _AUTO_PINNED_HOME = None def is_multiplex_active() -> bool: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index f61b56a8e6..a4a1e872f6 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1799,8 +1799,12 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None: """Disable and remove systemd/launchd service for a profile.""" import platform as _platform - # HERMES_HOME is set temporarily so _profile_suffix resolves the service name. + # The service name follows get_hermes_home(): bind the override (the seam a multiplexed + # dashboard/tui-gateway process reads) and mirror the env for identity-file readers, so a + # DELETE from a multi-profile dashboard names THIS profile's unit, never the host's bare one. + from hermes_constants import reset_hermes_home_override, set_hermes_home_override old_home = os.environ.get("HERMES_HOME") + home_token = set_hermes_home_override(str(profile_dir)) try: os.environ["HERMES_HOME"] = str(profile_dir) from hermes_cli.gateway import get_service_name, get_launchd_plist_path, user_systemd_unit_dir @@ -1827,6 +1831,7 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None: except Exception as e: print(f"⚠ Service cleanup: {e}") finally: + reset_hermes_home_override(home_token) os.environ.pop("HERMES_HOME", None) if old_home is not None: os.environ["HERMES_HOME"] = old_home diff --git a/hermes_constants.py b/hermes_constants.py index cd30be4afa..c1a5ee9f5b 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -190,6 +190,10 @@ def pin_process_hermes_home(path: str | Path | None) -> None: _PINNED_PROCESS_HERMES_HOME = None if path is None else str(path) +def process_hermes_home_is_pinned() -> bool: + return _PINNED_PROCESS_HERMES_HOME is not 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 diff --git a/tests/agent/test_secret_scope_pinned_launch_home.py b/tests/agent/test_secret_scope_pinned_launch_home.py index 32f580ee51..16fafd17b0 100644 --- a/tests/agent/test_secret_scope_pinned_launch_home.py +++ b/tests/agent/test_secret_scope_pinned_launch_home.py @@ -1,24 +1,28 @@ -"""The launch home is frozen once the process serves several profiles (#119242). +"""The launch home is pinned 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. +``hermes_constants.pin_process_hermes_home`` (salvaged from #119129) gives the four routed-profile +decisions one stable launch-home identity; ``set_multiplex_active(True)`` pins it automatically for +a multiplexing gateway/dashboard, since neither calls the pin itself. Two contracts around that: + +* ``get_process_hermes_home()`` / ``get_hermes_home()`` keep following ``HERMES_HOME`` while the + pin holds — a host's env mirror exists so override-less readers see the served profile, and an + env-only home switch (``profiles._cleanup_gateway_service``) must not resolve to the launch home. +* Deactivation releases only the pin activation itself created: an embedding host's explicit pin + survives the transient toggles in ``gateway_migrate._multiplex_read_mode`` and cron workers. """ 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 +from agent.secret_scope import serves_routed_profile, set_multiplex_active +from hermes_constants import ( + get_hermes_home, + get_process_hermes_home, + get_routing_process_hermes_home, + reset_hermes_home_override, + set_hermes_home_override, +) @pytest.fixture @@ -28,39 +32,39 @@ def homes(tmp_path, monkeypatch): launch.mkdir() served.mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(launch)) - monkeypatch.setattr(hermes_constants, "_PINNED_PROCESS_HOME", None, raising=False) + monkeypatch.setattr(hermes_constants, "_PINNED_PROCESS_HERMES_HOME", None, raising=False) return launch, served -def test_a_per_turn_env_mirror_cannot_relabel_the_launch_home_under_multiplex(homes, monkeypatch): +def test_multiplex_activation_pins_the_launch_home_but_env_readers_still_follow_the_env(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_routing_process_hermes_home() == launch + # Only routing DECISIONS are frozen; override-less readers see what the env names. 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)) + assert get_hermes_home() == served + set_multiplex_active(False) + # The auto-pin is released with the mode: a standalone process follows the env again. + assert get_routing_process_hermes_home() == 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 + + +def test_an_explicit_host_pin_survives_a_transient_multiplex_toggle(homes, monkeypatch): + """#119242 contract: the embedding host pins once; ``_multiplex_read_mode`` / a cron worker + flipping the mode True→False in the same process must not drop it.""" + launch, served = homes + hermes_constants.pin_process_hermes_home(launch) + monkeypatch.setenv("HERMES_HOME", str(served)) + token = set_hermes_home_override(served) + try: + set_multiplex_active(True) + set_multiplex_active(False) + assert get_routing_process_hermes_home() == launch + assert serves_routed_profile() + finally: + reset_hermes_home_override(token) diff --git a/tests/conftest.py b/tests/conftest.py index 33f4648bff..38620e0bc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -572,9 +572,8 @@ 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) + if secret_scope_mod is not None and hasattr(secret_scope_mod, "_AUTO_PINNED_HOME"): + monkeypatch.setattr(secret_scope_mod, "_AUTO_PINNED_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) diff --git a/tests/hermes_cli/test_profile_delete_service_name.py b/tests/hermes_cli/test_profile_delete_service_name.py new file mode 100644 index 0000000000..789c72a732 --- /dev/null +++ b/tests/hermes_cli/test_profile_delete_service_name.py @@ -0,0 +1,51 @@ +"""Deleting a profile from a multiplexed process removes THAT profile's service, never the host's. + +A multi-profile dashboard/tui-gateway process has ``set_multiplex_active(True)`` and the launch +home pinned for routed-profile decisions. ``DELETE /api/profiles/`` then runs +``profiles._cleanup_gateway_service`` in that same process, which switches the home to the victim +to resolve ``get_service_name()``. If that switch were invisible to ``get_hermes_home()`` the name +would resolve to the launch home's bare ``hermes-gateway`` and the cleanup would disable, stop and +unlink the HOST multiplexer's own unit. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agent.secret_scope import set_multiplex_active +from hermes_cli import profiles + + +@pytest.fixture +def two_homes(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + launch = tmp_path / ".hermes" + victim = launch / "profiles" / "victim" + victim.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + unit_dir = tmp_path / "xdg" / "systemd" / "user" + unit_dir.mkdir(parents=True) + host_unit = unit_dir / "hermes-gateway.service" + victim_unit = unit_dir / "hermes-gateway-victim.service" + host_unit.write_text("[Service]\n") + victim_unit.write_text("[Service]\n") + return launch, victim, host_unit, victim_unit + + +@pytest.mark.linux_only +def test_delete_from_a_pinned_multiplexer_targets_the_victims_unit_only(two_homes, monkeypatch): + launch, victim, host_unit, victim_unit = two_homes + calls: list[list[str]] = [] + monkeypatch.setattr(profiles.subprocess, "run", lambda cmd, **kw: calls.append(list(cmd))) + set_multiplex_active(True) # the dashboard has served a second profile: launch home pinned + + profiles._cleanup_gateway_service("victim", victim) + + assert not victim_unit.exists() + assert host_unit.exists(), "the host multiplexer's own unit must survive a profile delete" + assert ["systemctl", "--user", "disable", "hermes-gateway-victim"] in calls + assert not any(c[:3] == ["systemctl", "--user", "disable"] and c[3] == "hermes-gateway" for c in calls) + # The caller's process home is restored: the process still is the launch profile afterwards. + assert profiles.os.environ["HERMES_HOME"] == str(launch)