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:
Hermes Agent
2026-09-24 20:31:06 -05:00
committed by brooklyn!
parent 7d17132978
commit d0cb567273
3 changed files with 114 additions and 3 deletions

View File

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

View File

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

View File

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