fix(gateway): scoped liveness never borrows another home's record or the multiplexer roster

Slim redo of the copied-profile-dir guard (#119772) at the seams that actually leaked on main:

- get_runtime_status_running_pid(expected_home=...) now rejects a record whose hermes_home
  stamp names another home (rung 3 of resolve_gateway_liveness and every direct caller:
  live_gateway_pid_for_home, the dashboard messaging/status readers, the update inventory).
  Rung 1 already applied recorded_gateway_home_conflicts inside get_running_pid.
- A scoped read with no gateway_state.json hands rung 3 an empty record, never None -- None
  re-read the PROCESS home's record and lent its live PID to the copied directory.
- multiplexer_liveness_for_profile only answers for <default root>/profiles/<name>: the roster
  is matched by name and a same-named directory under another root is not the served home.

Two invariant tests replace the PR's four (same contract, real records instead of probe stubs).
This commit is contained in:
teknium1
2026-09-23 02:46:47 -07:00
committed by Teknium
parent d292055bae
commit 3532a60bfa
2 changed files with 68 additions and 164 deletions

View File

@@ -293,35 +293,6 @@ def scoped_lock_owner_label(record: Optional[dict[str, Any]]) -> Optional[str]:
return _profile_label_for_home(home) if isinstance(home, str) and home.strip() else None
def _recorded_pid_home_conflict(profile_dir: Path) -> bool:
"""``gateway.pid`` record names a DIFFERENT HERMES_HOME than ``profile_dir`` -> True.
A profile directory can be copied wholesale (sandbox injection, restore-from-backup,
cloning a profile). Its ``gateway.pid`` then belongs to a gateway of another home, and the
PID rung reports "gateway running" for a home that has none -- while ``hermes cron`` or
``gateway list`` says the opposite on the same machine.
"""
try:
with open(profile_dir / "gateway.pid", encoding="utf-8") as fh:
record = json.load(fh)
except Exception:
return False
return recorded_gateway_home_conflicts(record, expected_home=profile_dir)
def _profile_dir_is_default_root_profile(profile_dir: Path) -> bool:
"""True when ``profile_dir`` is ``<default root>/profiles/<name>`` (the pooled layout).
Rung 4 matches the multiplexer by profile *name*; a copied profile directory carries the
same name but no relation to that multiplexer, so the rung must not apply to it.
"""
try:
from hermes_constants import get_default_hermes_root
return (Path(get_default_hermes_root()).resolve() / "profiles") == Path(profile_dir).resolve().parent
except Exception:
return False
def _get_pid_path() -> Path:
return _get_process_hermes_home() / "gateway.pid"
@@ -1302,6 +1273,11 @@ def multiplexer_liveness_for_profile(profile_dir: Path) -> Optional[tuple[int, d
from hermes_cli.gateway import named_profile_served_by_running_multiplexer
from hermes_cli.gateway_multiplex_served import live_default_gateway_pid
from hermes_constants import get_default_hermes_root
# The roster is matched by NAME, and the multiplexer only serves ``<default root>/profiles/<name>``:
# a profile directory copied to another root (sandbox, restore-from-backup) keeps the name but is
# not the home being served, so it must not borrow the multiplexer's PID.
if name != "default" and not _same_hermes_home(profile_dir, get_default_hermes_root() / "profiles" / name):
return None
topology = host_gateway_topology()
if topology is not None and topology.serves(name):
pid: Optional[int] = topology.pid
@@ -1395,10 +1371,6 @@ def resolve_gateway_liveness(
# Zero-arg call when unscoped: callers monkeypatch with zero-arg lambdas and
# /api/status's cache signature is keyed on the call shape.
pid = guarded(_pid_probe, profile_dir / "gateway.pid") if scoped else guarded(_pid_probe)
if pid is not None and scoped and _recorded_pid_home_conflict(profile_dir):
# The record belongs to another home (copied profile dir) -> not this home's gateway.
# Fall through to the remaining rungs instead of reporting a false green.
pid = None
if pid is not None:
return GatewayLiveness(running=True, pid=pid, source="pid")
health_body: Optional[dict[str, Any]] = None
@@ -1413,18 +1385,11 @@ def resolve_gateway_liveness(
if runtime is _UNSET:
reader_kwargs = {"path": profile_dir / "gateway_state.json"} if scoped else {}
runtime = guarded(_runtime_reader, **reader_kwargs)
# A scoped read that found no record must not degrade into "read the PROCESS home's file":
# ``get_runtime_status_running_pid(None, ...)`` re-reads the default path, and a copied profile
# directory keeps the profile NAME -- so the serving gateway's record would lend it a running PID
# it does not own. Hand rung 3 an empty record instead of None.
# A scoped home with no ``gateway_state.json`` gets an EMPTY record, never ``None``: ``None`` makes
# the probe re-read the PROCESS home's record and lend that gateway's PID to a home it does not
# own (a profile directory copied out of another root).
probe_kwargs = {"expected_home": profile_dir} if scoped else {}
runtime_pid = guarded(
_runtime_pid_probe, {} if (scoped and runtime is None) else runtime, **probe_kwargs
)
if runtime_pid is not None and scoped and recorded_gateway_home_conflicts(
runtime if isinstance(runtime, dict) else None, expected_home=profile_dir):
# Same stale-green shape as rung 1, via a copied gateway_state.json.
runtime_pid = None
runtime_pid = guarded(_runtime_pid_probe, {} if (scoped and runtime is None) else runtime, **probe_kwargs)
if runtime_pid is not None:
return GatewayLiveness(
running=True, pid=runtime_pid, source="runtime_status", health_body=health_body
@@ -1434,10 +1399,6 @@ def resolve_gateway_liveness(
# question is about the process's OWN home — which is a named profile inside a pooled
# `hermes --profile X serve` (the Desktop's per-profile backend answers its REST without
# `?profile=`), so it takes the same rung instead of reporting the served profile stopped.
if scoped and not _profile_dir_is_default_root_profile(profile_dir):
return GatewayLiveness(
running=False, pid=None, source="none", health_body=health_body, probe_error=probe_error
)
own_home = profile_dir if scoped else _get_process_hermes_home()
served = guarded(multiplexer_liveness_for_profile, own_home)
if served is not None:
@@ -1465,10 +1426,13 @@ def get_runtime_status_running_pid(
pid = _live_pid_from_record(payload)
if pid is None:
return None
# Active-profile context: the record's hermes_home must match this process so a stale record
# cannot lend another profile's identity.
# The record's hermes_home must match the home asked about (this process unscoped) so a stale
# or copied record cannot lend another home's gateway identity; legacy records without the
# stamp prove nothing either way and fall through to the live command-line check.
if expected_home is None and not _pid_record_belongs_to_current_profile(payload):
return None
if expected_home is not None and recorded_gateway_home_conflicts(payload, expected_home=expected_home):
return None
if not _record_matches_live_gateway_pid(payload, pid, expected_home=expected_home):
return None
return pid