diff --git a/gateway/run_shutdown.py b/gateway/run_shutdown.py index 738a37670e..72ffa097e7 100644 --- a/gateway/run_shutdown.py +++ b/gateway/run_shutdown.py @@ -1403,6 +1403,18 @@ class GatewayShutdownMixin: settled_multiplex = is_multiplex_active() multiplex = False if not on_default and not settled_multiplex: + # Second settled source: the live host gateway's OWN published record + # (its settled served set). Only when NO settled identity exists may the + # raw config re-read stand — it reads the UNSET flag as False, which is + # wrong exactly when this process IS the default-on host (#120305). + try: + from gateway import host_rendezvous as hr + record = hr.read_record(hr.ROLE_GATEWAY) + if record is not None and hr.liveness_is_proven(record) and len(record.profiles) > 1: + multiplex = True + except Exception: + multiplex = False + if not on_default and not settled_multiplex and not multiplex: try: from gateway.config import load_gateway_config multiplex = bool(load_gateway_config().multiplex_profiles) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 93f8c70fb7..299b03ff3b 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -980,9 +980,15 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool: def _restart_argv_is_host_gateway(argv: list[str]) -> bool: """True when *argv* relaunches the host multiplexer, not a named profile's own gateway. - ``--profile `` (other than default) is that profile. No selector is the host - only when this process is already the default root — a worker ``gateway run`` has - no selector and must not be retargeted. + ``--profile `` (other than default) is that profile. A selector-less argv is + decided by ALREADY-SETTLED identity, never ambient coordinates alone (#93943): + + 1. this process's own settled multiplex verdict (``is_multiplex_active`` — set by + boot after ``resolve_multiplex_mode``; the gateway replaying its own restart); + 2. the live host gateway's published rendezvous record (proof the RUNNING owner + settled multiplex — the update/fleet process replaying a foreign gateway's + captured argv has no settled flag of its own); + 3. only then the compatibility default-root comparison. """ if not argv or "gateway" not in argv: return False @@ -999,6 +1005,19 @@ def _restart_argv_is_host_gateway(argv: list[str]) -> bool: from agent.secret_scope import is_multiplex_active if is_multiplex_active(): return True + except Exception: + pass + # The publishing gateway's SETTLED served set, not this process's ambient home: + # a host launched from a named profile must be replayed as the host even though + # the replaying process (the updater) sits on the named profile's home. + try: + from gateway import host_rendezvous as hr + record = hr.read_record(hr.ROLE_GATEWAY) + if record is not None and hr.liveness_is_proven(record) and len(record.profiles) > 1: + return True + except Exception: + pass + try: from hermes_constants import get_default_hermes_root, get_hermes_home return get_hermes_home().resolve() == get_default_hermes_root().resolve() except Exception: diff --git a/tests/gateway/test_host_gateway_profile_env.py b/tests/gateway/test_host_gateway_profile_env.py index 51117e364d..669df26b81 100644 --- a/tests/gateway/test_host_gateway_profile_env.py +++ b/tests/gateway/test_host_gateway_profile_env.py @@ -9,6 +9,7 @@ and a duplicate refusal must say which claim came from the environment. from __future__ import annotations from pathlib import Path +from typing import Sequence import pytest @@ -148,6 +149,85 @@ class TestHostGatewaySpawnEnv: assert env.get("TELEGRAM_BOT_TOKEN") != _WORKER_TOKEN +class TestSettledHostRecordDecidesRestart: + """An updater process (no settled flag of its own) replaying a host gateway's + captured argv / spawning a restart watcher must take the identity from the + live host record the gateway published — its SETTLED served set — and never + from ambient coordinates (current HERMES_HOME / raw config re-read). #120305, #93943.""" + + @staticmethod + def _publish_live_host_record(monkeypatch, tmp_path, *, home: Path, profiles: Sequence[str]) -> None: + """Publish a proven-live host record in an isolated lock dir.""" + from gateway import host_rendezvous as hr + lock_dir = tmp_path / "locks" + lock_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(lock_dir)) + record = hr.publish_record( + hr.ROLE_GATEWAY, profiles=tuple(profiles), home=str(home), + ) + assert record is not None, "fixture must publish a host record" + # Make the recorded PID provably live: it must match this process's own + # incarnation so liveness_is_proven() positively answers. + published = hr.read_record(hr.ROLE_GATEWAY) + assert published is not None + monkeypatch.setattr( + hr, "_pid_incarnation_matches", lambda pid, create_time: True, + ) + + def test_selectorless_replay_of_a_live_host_is_host_even_from_named_home( + self, tmp_path, monkeypatch, + ): + """The updater sits on the named profile's home; the host record proves hostness.""" + from hermes_cli.gateway import _restart_argv_is_host_gateway + + default_home, worker_home = _two_homes(tmp_path) + self._publish_live_host_record( + monkeypatch, tmp_path, home=worker_home, profiles=("default", "worker"), + ) + _inherit_worker_env(monkeypatch, worker_home) + + assert _restart_argv_is_host_gateway( + ["python", "-m", "hermes_cli.main", "gateway", "run"] + ), "a live host multiplexer's selector-less argv must replay as the host" + + def test_replay_stays_profile_scoped_without_a_live_host_record( + self, tmp_path, monkeypatch, + ): + """No live host record + a named-profile home => the argv is that profile's.""" + from hermes_cli.gateway import _restart_argv_is_host_gateway + + _default_home, worker_home = _two_homes(tmp_path) + (worker_home / "config.yaml").write_text("gateway: {}\n", encoding="utf-8") + _inherit_worker_env(monkeypatch, worker_home) + monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "empty-locks")) + + assert not _restart_argv_is_host_gateway( + ["python", "-m", "hermes_cli.main", "gateway", "run"] + ), "without settled proof a named-home process must not mint host authority" + + def test_restart_watcher_uses_the_live_host_record_when_no_flag_is_set( + self, tmp_path, monkeypatch, + ): + """An unset raw config + no settled flag + a live host record => host env.""" + from gateway.run_shutdown import GatewayShutdownMixin + + default_home, worker_home = _two_homes(tmp_path) + (worker_home / "config.yaml").write_text("gateway: {}\n", encoding="utf-8") + self._publish_live_host_record( + monkeypatch, tmp_path, home=worker_home, profiles=("default", "worker"), + ) + _inherit_worker_env(monkeypatch, worker_home) + + env = GatewayShutdownMixin._restart_watcher_env() + + assert env.get("HERMES_HOME") == str(default_home), ( + "the live host record's settled identity must select the default root" + ) + assert env.get("TELEGRAM_BOT_TOKEN") != _WORKER_TOKEN, ( + "the named profile's credential must not be donated to the host watcher" + ) + + class TestDuplicateRefusalNamesEnvClaim: @pytest.mark.asyncio async def test_refusal_says_which_claim_is_env_derived(self, tmp_path, monkeypatch):