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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
51
tests/hermes_cli/test_profile_delete_service_name.py
Normal file
51
tests/hermes_cli/test_profile_delete_service_name.py
Normal 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)
|
||||
Reference in New Issue
Block a user