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/<x> 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.
This commit is contained in:
teknium1
2026-09-23 06:12:35 -07:00
committed by Teknium
parent c7c9c18ccf
commit bd970b0588
6 changed files with 127 additions and 50 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/<name>`` 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)