Files
hermes-agent/hermes_cli/update_host_obligation.py
Teknium 1237193f46 fix(windows): dirs made while elevated stay usable; an unusable token-lock dir parks once
An elevated Hermes (install, `hermes update` as admin, a gateway started from an
admin prompt) creating a dir with mode=0o700 gets CPython's protected
SYSTEM + Administrators + OWNER RIGHTS DACL, owned by Administrators. The same
user's non-elevated Desktop/gateway/CLI then gets WinError 5 inside it (#139845).

- hermes_dir_acl.make_user_dir: Path.mkdir that, only when the process is
  elevated on Windows, adds an inheriting full-control ACE for the token's user
  SID to every dir it created and to an existing leaf (repairs a dir an earlier
  elevated run left). No-op off Windows / unelevated. Routed through it: the
  gateway lock dir (scoped token locks, host rendezvous, update host
  obligation), bot mailbox, gateway.lock homes, pending_messages, media/attachment
  roots, process logs, spill dirs, vault, outbox, backups, home skeleton and
  mkdir_under_hermes_home (logs).
- W2: acquire_scoped_lock raises ScopedLockDirUnusable when this token may not
  use gateway-locks (or read a stale lock in it), logged WARNING once per dir with
  the repair (`icacls DIR /reset /t`). _acquire_platform_lock parks the adapter
  as non-retryable `lock_dir_unusable` naming the dir and the fix, instead of a
  raw PermissionError that Telegram retried forever.
- Builds on @KoNit-K's update-time ACL repair (#139870), which re-grants the
  whole home after an elevated update.

No Windows host was available: the ACL behavior is covered by
tests/test_hermes_dir_acl_windows.py (Windows CI) and needs a live retest.

Refs #134889 (W8, W2)
Refs #139845
Refs #140337
2026-10-17 08:03:52 -07:00

481 lines
22 KiB
Python

"""Host-scoped update→restart obligation for ``hermes update``.
Multiplex-only (Teknium ruling): exactly ONE ``hermes gateway run`` per host serves every
profile, so "this pull still owes the fleet a restart" is a property of the HOST, not of one
profile's ``HERMES_HOME``. The legacy ``$HERMES_HOME/fleet_restart_pending`` marker was
per-home: ``hermes -p coder update`` armed and cleared coder's copy while restarting the
SHARED process, and every other profile's CLI could neither see nor discharge that obligation
— it simply armed its own and re-killed the same host process.
The record therefore lives beside the host rendezvous record, in
:func:`gateway.host_rendezvous.host_state_dir` (``$HERMES_GATEWAY_LOCK_DIR`` else
``$XDG_STATE_HOME/hermes/gateway-locks``) — the one cross-profile, per-OS-user state root the
tree already has. It is written once per host and installation (keyed by the checkout), read by
every profile's CLI, and cleared once.
The same "one host process, not one per profile" identity is what
:func:`collapse_units_to_host_processes` applies to enumerated systemd units: leftover
per-profile ``hermes-gateway-<p>.service`` units on a multiplexed host all point at the same
live ``MainPID``, so restarting each one restarts the host process N times.
"""
from __future__ import annotations
import base64
import contextlib
import hashlib
import json
import logging
import os
import stat
import sys
import tempfile
import time
from pathlib import Path
from typing import Any, Callable, Iterable, Optional
from hermes_dir_acl import make_user_dir
logger = logging.getLogger("hermes_cli.update_cmd")
#: The record before it carried an install key, beside ``host-gateway.json`` / ``host-serve.json``.
#: Still read, and cleared by a discharge that judged it, so a debt an older release armed survives
#: the upgrade; it names no install, so it keeps its old meaning: owed by whichever install reads it.
HOST_OBLIGATION_NAME = "host-update-restart.json"
#: The per-``HERMES_HOME`` breadcrumb an unwritable host record falls back to; every reader honours it.
PROFILE_MARKER_NAME = "fleet_restart_pending"
_RECORD_VERSION = 1
def host_obligation_path() -> Path:
"""This installation's host obligation record.
Keyed by the checkout this module runs from: every profile of one install arms, reads and
clears the same record, while a second install of the same OS user (another checkout, a ZIP
install) owes its own, so its completed restart never erases this install's debt (review S3).
"""
# Not ``gateway.host_rendezvous.host_state_dir``: ``gateway.status`` imports ``utils`` -> ruamel,
# absent from the historical interpreter an old updater's takeover arms this record in; the
# recovery module's stdlib copy of the rule is drift-tested against the gateway resolver.
from hermes_cli.update_restart_recovery import _host_state_dir
install = os.path.normcase(str(Path(__file__).resolve().parents[1]))
key = hashlib.sha256(install.encode("utf-8")).hexdigest()[:16]
return Path(_host_state_dir()) / f"{Path(HOST_OBLIGATION_NAME).stem}-{key}.json"
def _owed_path() -> Path:
"""The record a reader honours: this install's, else an older release's unkeyed one."""
path = host_obligation_path()
legacy = path.with_name(HOST_OBLIGATION_NAME)
return legacy if not os.path.exists(path) and os.path.exists(legacy) else path # never raises
def _write_record(path: Path, record: dict) -> None:
"""``utils.atomic_json_write(path, record, mode=0o600)`` in stdlib only (see above)."""
replace_bytes(path, json.dumps(record, indent=2, ensure_ascii=False).encode("utf-8"))
def read_host_obligation() -> Optional[dict]:
"""The published obligation record, or ``None`` when absent/corrupt/foreign-versioned."""
return _read_record(_owed_path())
def _read_record(path: Path) -> Optional[dict]:
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, UnicodeDecodeError, ValueError):
return None
if not isinstance(payload, dict) or payload.get("version") != _RECORD_VERSION:
return None
return payload
def host_obligation_present() -> bool:
"""True when the record FILE exists, parseable or not.
Fail-closed: a corrupt record is an obligation whose terms are unknown, never a discharged
one — the restart is still owed and the reader falls back to "no recorded inventory".
"""
try:
return _owed_path().is_file()
except OSError:
return False
def amend_host_obligation(**fields: Any) -> None:
"""Merge ``fields`` into the armed record (test/diagnostic surface). Never raises."""
_update_record(lambda record: record.update(fields), "amend")
def write_host_obligation(
*, expected_sha: str = "", runtimes: Optional[list] = None, profile: str = "", owner: str = ""
) -> bool:
"""Arm the host obligation. True when it was written. Never raises.
Re-arming from a second profile for the SAME pulled SHA keeps the existing record (and its
``restarted`` proof) instead of resetting it: the host owes one restart, not one per profile.
``owner`` (an update run's commit-point token) joins the record's ``owners``: a run that
fails before its move hands back only its own stake (``release_host_obligation``), never
another run's debt for the same SHA. The first owner also stores what it found there
(``found``), which the last owner to leave puts back; an owner retargeting to another SHA
keeps that baseline (``_baseline_for``).
"""
path = host_obligation_path()
try:
with _record_mutex(path):
return _write_locked(path, expected_sha=expected_sha, runtimes=runtimes, profile=profile, owner=owner)
except OSError as exc: # the mutex busy or unopenable: the caller falls back to the per-home marker
logger.debug("Could not lock the host update-restart obligation: %s", exc)
return False
def arm_host_obligation(
marker: Path, *, expected_sha: str = "", runtimes: Optional[list] = None, profile: str = "", owner: str = ""
) -> bool:
"""Arm the host record, else ``marker`` (the arming profile's per-home breadcrumb). Never raises.
The one fallback for every arm (``hermes update``'s commit point, a historical updater's
takeover). An unwritable host state dir (``HERMES_GATEWAY_LOCK_DIR`` on a read-only mount, a
container UID that does not own ``$HOME``) must never disarm the obligation (#117275), but the
marker is visible to the arming profile only: it settles the debt only on an install PROVEN to
have no other profile, and an unreadable profile roster proves nothing (review S2 residual).
Otherwise the marker is still left for this profile, the gap is said out loud, and the result
is False: the commit point refuses to move; a takeover, whose tree already moved, keeps the warning.
"""
if write_host_obligation(expected_sha=expected_sha, runtimes=runtimes, profile=profile, owner=owner):
return True
lines = [f"started={time.time()}", f"pid={os.getpid()}"] + ([f"expected_sha={expected_sha}"] if expected_sha else [])
if runtimes is not None:
lines.append("inventory=" + json.dumps({"version": 1, "runtimes": runtimes}))
hidden = ""
try:
replace_bytes(marker, ("\n".join(lines) + "\n").encode("utf-8"))
except OSError as exc:
hidden = f" or {marker} ({exc})"
if not hidden:
try:
if _named_profiles_exist():
hidden = f" ({marker} hides it from the other profiles)"
except OSError as exc:
hidden = f" ({marker} may hide it from other profiles: the profile list is unreadable: {exc})"
if not hidden:
logger.warning("Host update-restart obligation (%s) is unwritable; armed the per-home marker %s instead.",
host_obligation_path(), marker)
return True
logger.error("Could not arm the update-restart obligation in %s%s; an interrupted update will not warn.",
host_obligation_path(), hidden)
# ASCII: a historical updater's takeover may print through a legacy console code page.
print(" Warning: could not record the pending gateway-restart obligation where every profile sees it "
"(state dir not writable) - restart gateways with `hermes gateway restart` if this update is "
"interrupted.", file=sys.stderr)
return False
def _named_profiles_exist() -> bool:
"""True when the install has a live named profile beside ``default``, read from a COMPLETE roster.
``profiles.list_profile_names`` suppresses enumeration errors and reads an unreadable roster as
"default only"; here any error listing the roster or reading an entry's identity raises instead.
Same identity rule as ``hermes_constants.named_profile_is_live`` (stdlib only: see above).
"""
from hermes_constants import _PROFILE_IDENTITY_MARKERS, PROFILE_ID_RE, get_default_hermes_root, profile_tombstone_path
try:
entries = list((get_default_hermes_root() / "profiles").iterdir())
except (FileNotFoundError, NotADirectoryError):
return False
for home in entries:
if home.name == "default" or not PROFILE_ID_RE.match(home.name) or not stat.S_ISDIR(_mode(home, os.stat)):
continue
identity = any((mode := _mode(home / name, os.lstat)) and not stat.S_ISDIR(mode)
for name in _PROFILE_IDENTITY_MARKERS)
if identity and not _mode(profile_tombstone_path(home), os.lstat):
return True
return False
def _mode(path: Path, probe: Callable[[Path], os.stat_result]) -> int:
"""``probe(path).st_mode``; 0 when absent. Any other error raises: unreadable is unknown, not absent."""
try:
return probe(path).st_mode
except (FileNotFoundError, NotADirectoryError):
return 0
def _write_locked(path: Path, *, expected_sha: str, runtimes: Optional[list], profile: str, owner: str) -> bool:
existing = _read_record(path)
try:
found = _baseline_for(owner, existing, path) if owner else None
except OSError as exc: # unreadable is not absent: a guessed ``found`` would delete it later
logger.debug("Could not read the host update-restart obligation: %s", exc)
return False
if existing is not None and expected_sha and existing.get("expected_sha") == expected_sha:
# Same pull, second profile: the host owes ONE restart, so keep the standing record (and
# any proof that the restart already happened) rather than resetting it. A later arm that
# carries the owed inventory still upgrades it — an inventory-less record owes no set.
fields: dict[str, Any] = {}
owners = _owners(existing)
if owner and owner not in owners:
fields["owners"] = [*owners, owner]
if not owners:
fields["found"] = found
inventory = {"version": 1, "runtimes": runtimes}
if runtimes is not None and existing.get("inventory") != inventory:
fields["inventory"] = inventory
if not fields:
return True
try:
_write_record(path, {**existing, **fields})
except Exception as exc: # health: allow BLE001 -- never raises: the caller falls back to the per-home marker
logger.debug("Could not amend host update-restart obligation: %s", exc)
return False
return True
payload: dict[str, Any] = {
"version": _RECORD_VERSION,
"started": time.time(),
"pid": os.getpid(),
"armed_by_profile": profile or "",
"expected_sha": expected_sha or "",
}
if owner:
payload["owners"] = [owner]
payload["found"] = found
if runtimes is not None:
payload["inventory"] = {"version": 1, "runtimes": runtimes}
try:
make_user_dir(path.parent, parents=True, exist_ok=True)
_write_record(path, payload)
except Exception as exc: # health: allow BLE001 -- never raises: the caller falls back to the per-home marker
logger.debug("Could not write host update-restart obligation: %s", exc)
return False
return True
@contextlib.contextmanager
def _record_mutex(path: Path):
"""Serialize every read-judge-write of the record across processes (``update_lock.marker_mutex``).
The sidecar sits in a subdirectory: the state dir is the gateway lock dir, whose ``--replace``
cleanup unlinks every top-level ``*.lock``, and a deleted sidecar splits the lock in two.
"""
# One guard for every install's record (and the unkeyed one an older release still locks).
guard = path.parent / f".{Path(HOST_OBLIGATION_NAME).stem}.mutex"
make_user_dir(guard, parents=True, exist_ok=True)
with _marker_mutex()(guard / "record"):
yield
def _marker_mutex():
"""``update_lock.marker_mutex``. A historical updater imported ``update_lock`` before its pull and
then lazily imports this pulled module, so its in-memory copy predates the mutex: load the pulled
``update_lock`` (stdlib-only) beside it, which locks the same sidecar every current process does."""
import importlib.util
import sys
from hermes_cli import update_lock
if hasattr(update_lock, "marker_mutex"):
return update_lock.marker_mutex
name = "hermes_cli._update_lock_pulled"
pulled = sys.modules.get(name)
if pulled is None:
spec = importlib.util.spec_from_file_location(name, update_lock.__file__)
pulled = importlib.util.module_from_spec(spec)
sys.modules[name] = pulled # before exec: dataclasses resolve the module by name
spec.loader.exec_module(pulled)
return pulled.marker_mutex
def _owners(record: dict) -> list[str]:
owners = record.get("owners")
return [str(o) for o in owners] if isinstance(owners, list) else []
def _baseline_for(owner: str, existing: Optional[dict], path: Path) -> Optional[str]:
"""What ``owner``'s release must put back (base64; ``None`` = absent): the record as it stood
before ``owner``'s first stake in it (review R1).
A run re-arms with one owner as it retargets (CP0 branch, origin pull, upstream fork ff), so
"the record already has owners" never means "nothing was here": a new owner keeps the bytes
there now, other owners' stakes included; an owner already in the record carries the baseline
it saved then, or, when it shared the record with others, the shared record minus its own stake.
"""
owners = _owners(existing or {})
if owner not in owners:
return _found_field(path)
others = [o for o in owners if o != owner]
if not others:
return (existing or {}).get("found")
shared = json.dumps({**(existing or {}), "owners": others}, indent=2, ensure_ascii=False)
return base64.b64encode(shared.encode("utf-8")).decode("ascii")
def _found_field(path: Path) -> Optional[str]:
"""The record's current bytes (base64), ``None`` when absent: what a first owner puts back.
Any other read error raises: custody is never guessed."""
try:
return base64.b64encode(path.read_bytes()).decode("ascii")
except FileNotFoundError:
return None
def release_host_obligation(owner: str) -> None:
"""Hand back ``owner``'s stake in the record. When no other run still owes through it, the
record goes back to what its first owner found (``found``; absent = unlinked).
A record without ``owner`` was rewritten since (another run's newer pull): it is theirs and
stays. Raises OSError when the record cannot be rewritten (the caller keeps the debt armed).
Judged and rewritten under ``_record_mutex``, which every arm takes too, so no arm can land
between the look and the write and be undone by it (kshitijk4poor F22/N05).
"""
path = host_obligation_path()
if not owner or owner not in _owners(_read_record(path) or {}):
return # nothing of ours to hand back: no lock taken, no state dir created
with _record_mutex(path):
_release_locked(path, owner)
def _release_locked(path: Path, owner: str) -> None:
record = _read_record(path)
owners = _owners(record) if record is not None else []
if owner not in owners:
return # rewritten since the unlocked look (another run's arm): theirs now
remaining = [o for o in owners if o != owner]
if remaining:
_write_record(path, {**record, "owners": remaining})
return
try:
found = base64.b64decode(record["found"], validate=True) if record.get("found") is not None else None
except (TypeError, ValueError):
return # a damaged ``found`` cannot be put back: the debt stays armed, never guessed away
if found is None:
path.unlink(missing_ok=True)
else:
replace_bytes(path, found)
def replace_bytes(path: Path, data: bytes) -> None:
"""``data`` at ``path`` as one durable record: a fresh 0600 ``mkstemp`` file (never written
through a planted alias), fsynced, renamed over ``path``, then the rename itself made durable."""
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.stem}_", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
if os.name != "nt": # Windows opens no directory handle; NTFS journals the rename itself
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
def clear_host_obligation() -> None:
"""Discharge the record this install's readers judged (``_owed_path``). Never raises.
That is this install's own record whenever it has one. An older release's unkeyed record names
no install: while this install has its own record, the unkeyed one may be another
installation's standing debt that nothing here judged, so it stays (review S3 residual). It goes
only when it was the record judged (this install armed none): it keeps its old meaning, owed by
whichever install reads it, and an upgrade's own interrupted debt must stay dischargeable.
"""
try:
_owed_path().unlink(missing_ok=True)
except OSError as exc:
logger.debug("Could not clear host update-restart obligation: %s", exc)
def obligation_fields() -> Optional[dict[str, str]]:
"""The obligation in the legacy ``key=value`` field shape, or ``None`` when unarmed.
Keeps one parser for both sources: the fields a reader needs (``expected_sha``, the
serialized ``inventory``) are identical whether they came from the host record or from an
in-flight legacy per-home marker.
"""
record = read_host_obligation()
if record is None:
return None
fields = {"expected_sha": str(record.get("expected_sha") or "")}
inventory = record.get("inventory")
if inventory is not None:
fields["inventory"] = json.dumps(inventory)
return fields
def mark_host_restart_completed(sha: str) -> None:
"""Record that the host process was restarted onto ``sha``. Never raises."""
_update_record(
lambda record: record.update(restarted={"sha": sha or "", "pid": os.getpid(), "at": time.time()}),
"stamp restart completion on")
def _update_record(change: Callable[[dict], None], what: str) -> None:
"""Read, ``change`` and rewrite an armed record under ``_record_mutex`` (an arm's new owner
stake is never overwritten by a stale copy). Never raises; no record, no write."""
path = _owed_path()
if _read_record(path) is None:
return
try:
with _record_mutex(path):
record = _read_record(path)
if record is None:
return
change(record)
_write_record(path, record)
except Exception as exc: # health: allow BLE001 -- never raises: a lost stamp costs a repeat restart, never the debt
logger.debug("Could not %s host update-restart obligation: %s", what, exc)
def host_restart_already_completed(sha: Optional[str]) -> bool:
"""True when THIS host obligation was already restarted onto ``sha``.
The guard that makes the catch-up restart idempotent per host: a second profile running
``hermes update`` must attach to the first restart's outcome, never kill the shared
multiplexer again.
"""
record = read_host_obligation()
if record is None or not sha:
return False
restarted = record.get("restarted")
return isinstance(restarted, dict) and str(restarted.get("sha") or "") == sha
def collapse_units_to_host_processes(
units: Iterable[str], main_pid: Callable[[str], int]
) -> tuple[list[str], dict[str, str]]:
"""Split enumerated units into ``(restart, {legacy_unit: covering_unit})``.
Units resolving to the same live ``MainPID`` are ONE host process; restarting each of them
restarts that process N times, which on a multiplexed host is an N-fold outage triggered by
leftover per-profile units. A unit with no readable main PID (inactive, unprivileged scope)
keeps its own restart: identity that cannot be proved is never collapsed away.
"""
restart: list[str] = []
covered: dict[str, str] = {}
owner_by_pid: dict[int, str] = {}
for unit in units:
try:
pid = int(main_pid(unit) or 0)
except Exception:
# Identity that cannot be proved keeps its own restart; a probe failure of any kind
# must never abort the whole pass.
pid = 0
if pid <= 0:
restart.append(unit)
continue
owner = owner_by_pid.get(pid)
if owner is None:
owner_by_pid[pid] = unit
restart.append(unit)
else:
covered[unit] = owner
return restart, covered