fix(gateway): take host identity from the live host record on replay
The settled-flag fix covers a process that multiplexes itself. The update and fleet processes replay a FOREIGN gateway's captured argv with no settled flag of their own, so a selector-less argv fell back to the ambient HERMES_HOME comparison — the exact coordinate the review rejects (#93943): a host launched from a named profile was replayed as that profile, donating the named credentials to the respawned host. Both restart edges now consult, in order: this process's settled multiplex verdict, then the live host gateway's published rendezvous record (its SETTLED served set, proven live), and only then the compatibility default-root comparison. The raw config re-read stays last so no settled identity exists => unchanged compatibility behavior. Regressions: selector-less replay from a named home with a live host record is host; without one it stays profile-scoped; the restart watcher env takes the default root and drops the named token when only the host record proves hostness.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 <name>`` (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 <name>`` (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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user