Files
hermes-agent/gateway/host_rendezvous.py
2026-09-23 10:43:45 -04:00

613 lines
25 KiB
Python

"""Host-wide singleton rendezvous: one lock + one record per ROLE per OS user.
Multiplex-only (Teknium ruling): exactly ONE ``hermes serve`` and ONE ``hermes gateway run``
per host, each multiplexing every profile. The per-``HERMES_HOME`` gateway lock/PID files
(``gateway.status``) cannot express that — N profiles are N homes, so N processes each take
their own flock and none of them ever sees the others. This module adds the missing layer:
* a **host lock** (flock/``msvcrt``) held for the lifetime of the winning process, and
* a **rendezvous record** the winner publishes so a second invocation can find it, prove it
is the same live process, and ATTACH instead of binding a second port.
Both live in :func:`gateway.status._get_lock_dir` — the only cross-profile lock root already
in the tree (``$HERMES_GATEWAY_LOCK_DIR`` else ``$XDG_STATE_HOME/hermes/gateway-locks``),
which scopes to the **OS user**. That is the correct granularity: separate OS users have
separate ``$HOME``s, separate ``~/.hermes`` profile roots, separate ports-by-convention and
separate credentials, so "one per host" means "one per host per OS user".
**Staleness is proved, never assumed.** A record carries ``(pid, createTime)``; a record whose
PID is dead, or whose PID is alive with a different process creation time (PID reuse), is
STALE and is ignored — an attaching client must never dial a recycled PID's port.
**Relationship to ``spawn-ledger.json``** (``hermes_cli/process_identity.py``): the ledger stays
the append-only machine roster of every long-lived Hermes process (Desktop's attach ladder reads
it) and is still written unchanged. It cannot be the host record: it has no lock, no
single-writer semantics, no removal on exit, and no place to publish a protocol version or
an authentication handle. The record here is authoritative for "who owns this host role"; the
ledger remains authoritative for "what is running". Both are written, and this module reuses the
ledger's ``(pid, create_time)`` liveness proof rather than inventing a second one.
"""
from __future__ import annotations
import contextlib
import enum
import hashlib
import json
import logging
import os
import stat
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional, Sequence
from utils import atomic_json_write
logger = logging.getLogger(__name__)
#: Bumped when the record's shape or the attach handshake changes incompatibly. A reader that
#: does not recognise the version refuses to attach instead of guessing.
HOST_PROTOCOL_VERSION = 1
#: Token-gated endpoint the owner answers with its own identity. An attaching client dials the
#: RECORDED port and only attaches when the answer is the recorded owner — a record alone proves
#: nothing about who holds that port right now.
HOST_IDENTITY_PATH = "/api/host/identity"
#: Bounded: a closed port, a black-holed one or a foreign listener must cost a second, not a hang.
PROBE_TIMEOUT_S = 2.0
ROLE_GATEWAY = "gateway"
ROLE_SERVE = "serve"
#: A Desktop-owned pool child (loopback, random port, per-profile lifecycle). It is NOT a host
#: owner — the attach/refuse ladder reads ``ROLE_SERVE`` only, so a supervised public dashboard
#: never stands down behind it (#119824) — but ``hermes plugins install`` from a terminal still
#: has to reach the backend hosting the open chats (#119644), and this record + 0600 token is
#: how it dials one on a Desktop-only box.
ROLE_DESKTOP_SERVE = "desktop-serve"
_ROLES = (ROLE_GATEWAY, ROLE_SERVE, ROLE_DESKTOP_SERVE)
# Open lock handles, keyed by (role, resolved lock path): the OS releases the flock when this
# process dies, which is what makes a crashed owner's host lock re-acquirable without a reaper.
# The PATH is part of the key because the lock dir is env-derived (HERMES_GATEWAY_LOCK_DIR):
# keyed by role alone, a second call after the dir changed returned "already held" without ever
# creating the new lock file, so owns_host_lock() lied and one pytest process leaked the handle
# across tests.
_lock_handles: dict[tuple[str, str], Any] = {}
@dataclass(frozen=True)
class HostRecord:
"""A published host-role owner. ``profiles`` is the SERVED set, not the launch profile."""
role: str
pid: int
create_time: Optional[float]
host: str
port: Optional[int]
protocol_version: int
token_fingerprint: str
profiles: tuple[str, ...]
updated_at: str
#: HERMES_HOME the owner was launched from. The attach channel (``gateway.control_socket``) is
#: keyed by home, so without it a client can only guess the default root — wrong as soon as a
#: named profile launches the host process. Absent in records written before this field; added
#: WITHOUT a protocol bump on purpose, because a bump would make every live owner's record read
#: as stale and a second gateway would start.
home: str = ""
def to_json(self) -> dict[str, Any]:
return {
"role": self.role,
"home": self.home,
"pid": self.pid,
"createTime": self.create_time,
"host": self.host,
"port": self.port,
"protocolVersion": self.protocol_version,
"tokenFingerprint": self.token_fingerprint,
"profiles": list(self.profiles),
"updatedAt": self.updated_at,
}
@classmethod
def from_json(cls, payload: Any) -> Optional["HostRecord"]:
if not isinstance(payload, dict):
return None
pid = payload.get("pid")
role = payload.get("role")
if not isinstance(pid, int) or pid <= 0 or role not in _ROLES:
return None
create = payload.get("createTime")
port = payload.get("port")
profiles = payload.get("profiles")
version = payload.get("protocolVersion")
return cls(
role=role,
pid=pid,
create_time=float(create) if isinstance(create, (int, float)) else None,
host=str(payload.get("host") or ""),
port=int(port) if isinstance(port, int) and 0 < port <= 65535 else None,
protocol_version=version if isinstance(version, int) else 0,
token_fingerprint=str(payload.get("tokenFingerprint") or ""),
profiles=tuple(str(p) for p in profiles if isinstance(p, str)) if isinstance(profiles, list) else (),
updated_at=str(payload.get("updatedAt") or ""),
home=str(payload.get("home") or ""),
)
def host_state_dir() -> Path:
"""Per-OS-USER rendezvous dir (shared by every profile of this user)."""
from gateway.status import _get_lock_dir
return _get_lock_dir()
def ensure_host_state_dir() -> Path:
"""The rendezvous dir, created owner-only (``0o700``) and tightened if it is not.
A bare ``mkdir`` under the common ``umask 002`` leaves the dir group-writable, and the record
inside it is what every lifecycle verb believes: a same-group process could unlink+replace it
and choose this host's ATTACH answers (which home to dial, which profiles are "served").
Ownership of the dir is ours, so widening is repaired rather than refused.
"""
directory = host_state_dir()
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
if sys.platform != "win32":
with contextlib.suppress(OSError):
if stat.S_IMODE(directory.stat().st_mode) & 0o077:
os.chmod(directory, 0o700)
return directory
def _record_is_own(path: Path) -> bool:
"""True when ``path`` was written by THIS OS user inside a dir this user owns.
``read_record`` hands its result straight to :mod:`gateway.host_attach`, which dials the home
it names and believes the served set it carries. A hand-written record therefore buys an
attacker the lifecycle verdict for every profile on the host, so the file's ``st_uid`` — not
its contents — is what makes it a record at all. Windows sets no ACLs from mode bits; there
the dir already lives under the user's own state root.
"""
if sys.platform == "win32":
return True
try:
info = path.stat()
parent = path.parent.stat()
except OSError:
return False
uid = os.getuid() # windows-footgun: ok — unreachable on Windows (early return above)
if info.st_uid != uid or parent.st_uid != uid:
logger.warning(
"ignoring host record %s: owned by uid %s (expected %s)", path, info.st_uid, uid)
return False
return True
def _validated_role(role: str) -> str:
if role not in _ROLES:
raise ValueError(f"unknown host role: {role!r}")
return role
def record_path(role: str) -> Path:
return host_state_dir() / f"host-{_validated_role(role)}.json"
def lock_path(role: str) -> Path:
return host_state_dir() / f"host-{_validated_role(role)}.lock"
def token_path(role: str) -> Path:
return host_state_dir() / f"host-{_validated_role(role)}.token"
def token_fingerprint(token: str) -> str:
"""Short, non-reversible handle for a session token (safe to publish in the record)."""
return hashlib.sha256(token.encode("utf-8", "replace")).hexdigest()[:16] if token else ""
def process_create_time(pid: Optional[int] = None) -> Optional[float]:
"""Creation time of ``pid`` (default: this process); ``None`` when unknowable."""
from hermes_cli.process_identity import _process_create_time
return _process_create_time(pid)
def _pid_incarnation_matches(pid: int, create_time: Optional[float]) -> Optional[bool]:
"""Reuse the spawn ledger's proof: True/False when provable, ``None`` when it cannot say."""
from hermes_cli.process_identity import _pid_alive_matches
return _pid_alive_matches(pid, create_time)
def record_is_stale(record: Optional[HostRecord]) -> bool:
"""A record nobody may attach to: absent, unknown protocol, dead PID, or PID reuse.
``None`` from the liveness probe (no psutil, permission denied, an unexpected psutil error)
means UNPROVABLE, and an unprovable record is a CANDIDATE, never an owner: it survives this
predicate only to be handed to :func:`probe_owner`, which dials the recorded port and makes
the owner prove itself. Treating unprovable as dead would let a second process bind a second
port; treating it as alive *without the probe* made a record for a long-dead PID a permanent
silent outage (attach exited 0 forever, with nothing listening).
"""
if record is None:
return True
if record.protocol_version != HOST_PROTOCOL_VERSION:
return True
return _pid_incarnation_matches(record.pid, record.create_time) is False
def liveness_is_proven(record: HostRecord) -> bool:
"""True only when the PID+createTime probe positively matched (never on ``None``)."""
return _pid_incarnation_matches(record.pid, record.create_time) is True
def dial_host(record: HostRecord) -> str:
"""Address to dial for ``record``: a wildcard bind is reached over loopback."""
host = record.host or "127.0.0.1"
return "127.0.0.1" if host in ("0.0.0.0", "::", "*", "") else host
def probe_owner(record: HostRecord, *, timeout: float = PROBE_TIMEOUT_S) -> Optional[dict]:
"""Make the recorded endpoint prove it is this record's owner; ``None`` when it does not.
Two gates, both required: a bounded TCP connect (nothing listening → the owner is gone, even
though its record and PID may still look alive during a graceful-shutdown window) and an
identity GET authenticated with the 0600 token (a foreign listener that inherited the port
answers the connect but cannot answer as PID N of role R).
Returns the owner's identity payload (``pid``/``role``/``servesSpa``) on success.
"""
if not record.port:
return None
import socket
host = dial_host(record)
try:
with socket.create_connection((host, record.port), timeout=timeout):
pass
except OSError:
logger.debug("host %s owner does not answer at %s:%s", record.role, host, record.port)
return None
import urllib.request
token = read_token(record.role)
headers = {"X-Hermes-Token": token, "Authorization": f"Bearer {token}"} if token else {}
request = urllib.request.Request(
f"http://{host}:{record.port}{HOST_IDENTITY_PATH}", headers=headers)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 — fixed http scheme
if response.status != 200:
return None
payload = json.loads(response.read(65536).decode("utf-8", "replace"))
except Exception:
logger.debug("host %s identity probe failed at %s:%s", record.role, host, record.port, exc_info=True)
return None
if not isinstance(payload, dict):
return None
if payload.get("role") != record.role or payload.get("pid") != record.pid:
return None
return payload
def read_record(role: str, *, include_stale: bool = False) -> Optional[HostRecord]:
"""Published record for ``role``; ``None`` when absent, foreign, corrupt or (by default) stale."""
path = record_path(role)
if not _record_is_own(path):
return None
try:
raw = path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeDecodeError):
return None
try:
record = HostRecord.from_json(json.loads(raw))
except (ValueError, TypeError):
return None
if record is None:
return None
return record if include_stale or not record_is_stale(record) else None
def read_token(role: str) -> str:
"""Owner-written session token for ``role`` (``""`` when absent/unreadable).
This is the handle an attaching client uses when the backend is auth-gated and therefore
withholds its token from an unauthenticated ``GET /``. Confidentiality is enforced at WRITE
time by :func:`_write_private_text` (POSIX ``0o600``; an owner+SYSTEM-only protected DACL on
Windows, where mode bits set no ACLs at all). Reading it is therefore evidence of, not proof
of, same-OS-user authority — the authority boundary the host lock is scoped to.
"""
try:
return token_path(role).read_text(encoding="utf-8-sig").strip()
except (OSError, UnicodeDecodeError):
return ""
def record_token_is_consistent(record: HostRecord) -> bool:
"""Does the on-disk token still hash to the record's fingerprint?
A record published without a token (the gateway) has an empty fingerprint and is consistent
by definition. A mismatch means the record and the token file come from different
incarnations (a torn restart) — discovery must not attach with a token the owner rejects.
"""
if not record.token_fingerprint:
return True
return token_fingerprint(read_token(record.role)) == record.token_fingerprint
def _write_private_text(path: Path, text: str) -> None:
"""Create/replace ``path`` with owner-only content.
POSIX: ``0o600`` via tmp + atomic replace. Windows: mode bits set NO ACLs, so the same
``os.open`` would leave a live session token readable by whatever the inherited DACL grants;
the SSH runtime's protected owner+SYSTEM DACL writer is the repo's primitive for exactly this
credential class and is reused here. It also replaces in place, because ``os.replace`` onto a
token file another process still holds open fails on Windows.
"""
if sys.platform == "win32":
from hermes_cli.windows_ssh_runtime import write_private_file
write_private_file(path, text.encode("utf-8"))
return
tmp = path.with_name(path.name + ".tmp")
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
with contextlib.suppress(OSError):
os.fsync(handle.fileno())
except BaseException:
with contextlib.suppress(OSError):
os.unlink(str(tmp))
raise
os.replace(str(tmp), str(path))
class HostLockOutcome(enum.Enum):
"""Why a host-lock claim ended the way it did.
``COULD_NOT_OPEN`` is NOT contention: a read-only/undeletable lock dir (EROFS, EACCES,
ENOSPC) collapsed into the same "another owner holds it" log and sent operators hunting a
second gateway that never existed.
"""
ACQUIRED = "acquired"
HELD_BY_OTHER = "held-by-other"
COULD_NOT_OPEN = "could-not-open"
def _lock_key(role: str) -> tuple[str, str]:
path = lock_path(role)
with contextlib.suppress(OSError):
return (role, os.path.abspath(str(path)))
return (role, str(path))
def claim_host_lock(role: str) -> tuple[HostLockOutcome, Optional[OSError]]:
"""Take the host-wide lock for ``role``. Idempotent per (role, lock path).
Returns the outcome and, for ``COULD_NOT_OPEN``, the OSError that explains it.
"""
role = _validated_role(role)
key = _lock_key(role)
if _lock_handles.get(key) is not None:
return (HostLockOutcome.ACQUIRED, None)
from gateway.status import _try_acquire_file_lock
path = Path(key[1])
try:
ensure_host_state_dir()
handle = open(path, "a+", encoding="utf-8")
except OSError as exc:
logger.debug("host %s lock could not be opened at %s", role, path, exc_info=True)
return (HostLockOutcome.COULD_NOT_OPEN, exc)
if not _try_acquire_file_lock(handle):
with contextlib.suppress(OSError):
handle.close()
return (HostLockOutcome.HELD_BY_OTHER, None)
_lock_handles[key] = handle
return (HostLockOutcome.ACQUIRED, None)
def release_host_lock(role: str) -> None:
"""Release the host lock for ``role`` when this process holds it."""
handle = _lock_handles.pop(_lock_key(_validated_role(role)), None)
if handle is None:
return
from gateway.status import _release_file_lock
_release_file_lock(handle)
with contextlib.suppress(OSError):
handle.close()
def owns_host_lock(role: str) -> bool:
"""True when THIS process holds the host lock for ``role`` (re-probing our own flock lies)."""
return _lock_handles.get(_lock_key(_validated_role(role))) is not None
def publish_record(
role: str,
*,
host: str = "",
port: Optional[int] = None,
profiles: Sequence[str] = (),
token: Optional[str] = None,
home: str = "",
) -> Optional[HostRecord]:
"""Publish this process as the host owner of ``role``. ``None`` when the write failed.
``token`` (serve) is persisted 0600 next to the record and only its fingerprint is published.
``home`` is the launch HERMES_HOME — the key an attaching client needs to reach this owner's
control socket.
"""
role = _validated_role(role)
record = HostRecord(
role=role,
pid=os.getpid(),
create_time=process_create_time(),
host=str(host or ""),
port=int(port) if isinstance(port, int) and port > 0 else None,
protocol_version=HOST_PROTOCOL_VERSION,
token_fingerprint=token_fingerprint(token or ""),
profiles=tuple(str(p) for p in profiles),
updated_at=datetime.now(timezone.utc).isoformat(),
home=str(home or ""),
)
try:
ensure_host_state_dir()
if token:
# No record without its token: publishing one an attaching client cannot
# authenticate against would degrade to a silent "attach refused forever".
_write_private_text(token_path(role), token)
atomic_json_write(record_path(role), record.to_json(), mode=0o600)
except OSError:
logger.warning("host %s record could not be published; discovery will not find it", role, exc_info=True)
return None
_invalidate_attach_cache()
return record
def _invalidate_attach_cache() -> None:
"""Drop :mod:`gateway.host_attach`'s memo: the record it summarises just changed."""
with contextlib.suppress(Exception):
from gateway.host_attach import invalidate_host_gateway_cache
invalidate_host_gateway_cache()
def discard_dead_record(role: str) -> bool:
"""Retract the record for ``role`` when its owner is provably gone; True when one was removed.
A confirmed stop must retract the record too. Leaving it made ``gateway restart --all`` a
silent no-op: the re-entered ``gateway run`` read the corpse's record, decided ATTACH and
exited 0, so the host ended up with no gateway at all.
"""
role = _validated_role(role)
record = read_record(role, include_stale=True)
if record is None:
return False
if record.pid != os.getpid() and _pid_incarnation_matches(record.pid, record.create_time) is not False:
return False
for path in (record_path(role), token_path(role)):
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
_invalidate_attach_cache()
return True
def clear_record(role: str) -> None:
"""Remove this process's record + token on exit (never another owner's)."""
role = _validated_role(role)
existing = read_record(role, include_stale=True)
if existing is not None and existing.pid != os.getpid():
return
for path in (record_path(role), token_path(role)):
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
_invalidate_attach_cache()
# Roles this process must clean up on the way out, and the signal handlers we prepended.
_cleanup_roles: list[str] = []
_prev_signal_handlers: dict[int, Any] = {}
def _cleanup_role(role: str) -> None:
with contextlib.suppress(Exception):
clear_record(role)
with contextlib.suppress(Exception):
release_host_lock(role)
def _handle_terminating_signal(signum, frame) -> None:
"""Drop the record + token, then hand off to the handler we prepended to."""
import signal as _signal
for role in tuple(_cleanup_roles):
_cleanup_role(role)
prev = _prev_signal_handlers.get(signum)
if callable(prev):
prev(signum, frame)
return
if prev is _signal.SIG_IGN:
return
with contextlib.suppress(Exception):
_signal.signal(signum, _signal.SIG_DFL)
os.kill(os.getpid(), signum)
raise SystemExit(128 + int(signum))
def cleanup_on_exit(role: str) -> None:
"""Clear ``role``'s record + token and release its lock on exit — SIGTERM included.
``atexit`` alone is false advertising for the NORMAL stop: systemd stop, ``docker stop`` and
the update relaunch all send SIGTERM, and every terminating SIGTERM path here ends in the
default disposition (uvicorn's ``capture_signals`` re-raises it after its graceful shutdown),
which kills the process without running ``atexit``. The record then outlived its process and
the 0600 token kept a LIVE session token on disk indefinitely.
The handler only PREPENDS cleanup: whatever handler was installed before (uvicorn's graceful
shutdown, the exit-flush chain, a supervisor's) still runs, so the shutdown sequence is
unchanged.
"""
import atexit
import signal as _signal
import threading
role = _validated_role(role)
if role not in _cleanup_roles:
_cleanup_roles.append(role)
atexit.register(_cleanup_role, role)
if threading.current_thread() is not threading.main_thread():
return
for name in ("SIGTERM", "SIGBREAK"):
signum = getattr(_signal, name, None)
if signum is None or signum in _prev_signal_handlers:
continue
with contextlib.suppress(ValueError, OSError, RuntimeError):
prev = _signal.getsignal(signum)
_signal.signal(signum, _handle_terminating_signal)
_prev_signal_handlers[signum] = prev
def _multiplex_profiles_enabled() -> bool:
"""Will THIS process multiplex? An explicit ``true`` and an unset key both say yes, and an
explicit ``false`` is RETIRED (``hermes_cli.gateway_multiplex_mode``) — it is warned about and
ignored at boot, so it must not make the claim-time record advertise a narrower roster than
the process actually serves. Reading it here was the last place the retired flag still decided
topology, and it made CLI/dashboard report "standalone, serving default" while the runtime
multiplexed. The RUNTIME verdict (a boot-time guard refusal) narrows the record afterwards, in
``gateway.run._refresh_host_gateway_record``, which republishes the SETTLED set.
"""
return True
def served_profiles(*, multiplex: Optional[bool] = None) -> tuple[str, ...]:
"""Profiles this process multiplexes; ``()`` when the roster cannot be read.
``multiplex`` defaults to what this process's own config says. Hard-coding ``True`` here
published a record claiming EVERY profile from a gateway that would only ever serve its own,
and a second profile's supervised unit then stood down against a set nobody serves.
"""
try:
from hermes_cli.profiles import profiles_to_serve
enabled = _multiplex_profiles_enabled() if multiplex is None else bool(multiplex)
return tuple(name for name, _ in profiles_to_serve(multiplex=enabled))
except Exception:
logger.debug("served profile roster unavailable", exc_info=True)
return ()
def describe(record: HostRecord) -> str:
"""One-line human description used by attach messages and conflict logs."""
where = f"{record.host or '127.0.0.1'}:{record.port}" if record.port else "no bound port"
profiles = ", ".join(record.profiles) if record.profiles else "unknown"
return f"PID {record.pid} ({where}; profiles: {profiles})"