Files
hermes-agent/hermes_cli/gateway_migrate_guards.py
teknium1 7dde7a2424 fix(gateway): migrate --multiplex resumes from live state, compensates the whole destructive phase, and refuses an unknown default principal
Three P1 findings from the review of #111062 (fixes #110850 remainder):

- interrupted-detection keyed off `plan.default.has_gateway`, which is true for an
  installed-but-dead unit; `systemd_install` writes the unit before the start that
  can still be killed, so an apply interrupted at default/start (or mid-restart of
  a stopped default) was reported as "already multiplexed" and never recovered.
  `interrupted` is now derived from the manifest vs the LIVE default's recorded
  served_profiles: flag on + manifest + not every migrated profile served = resume.

- the compensation `try` began after `_remove_secondary_gateways` and the flag
  write, so a later secondary's stop, a unit's daemon-reload or the config write
  failing left the first secondary removed with no rollback. The flag write,
  removals and default bring-up now all sit inside one boundary that rolls back
  through the manifest; `_preflight_apply` refuses failures knowable from the plan
  (root system unit without a recorded User=, unresolvable recorded user, an
  unwritable config.yaml) before any working gateway is stopped.

- `_guard_unix_user` blocked an unknown SECONDARY principal but accepted
  `default_uid is None`; a default system unit whose User= this host cannot
  resolve is the same boundary from the other side and now blocks the update
  hook (same known uid still folds, different known uid still refuses).
2026-09-16 00:33:03 -07:00

167 lines
8.0 KiB
Python

"""Boundaries the AUTOMATIC multiplex migration (``hermes update``) must not cross, and the opt-out.
The multiplexer replaces a kernel-enforced boundary (separate UNIX users, separate service domains,
separate HERMES_HOME trees) with in-process isolation. An operator may choose that with
``hermes gateway migrate --multiplex``; an unattended update hook must not choose it for them.
``build_migration_plan`` records the same findings as NOTICES so a dry run shows them; only
:func:`maybe_auto_migrate_after_update` treats them as blockers (#109954).
"""
from __future__ import annotations
import contextlib
import os
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional
if TYPE_CHECKING:
from hermes_cli.gateway_migrate import MigrationPlan, ProfileGateway
# --------------------------------------------------------------------------- identity resolution
def _pid_uid(pid: int) -> Optional[int]:
"""Owner uid of a live process: ``/proc`` where it exists, ``ps`` on macOS; None when unknown."""
with contextlib.suppress(OSError):
return os.stat(f"/proc/{pid}").st_uid
from hermes_cli.gateway import is_macos
if not is_macos():
return None
with contextlib.suppress(OSError, ValueError, subprocess.SubprocessError):
result = subprocess.run(["ps", "-o", "uid=", "-p", str(pid)], capture_output=True, text=True, encoding="utf-8",
check=False, timeout=2)
if result.returncode == 0 and result.stdout.strip():
return int(result.stdout.strip())
return None
def _system_unit_uid(unit_path: Path) -> Optional[int]:
"""uid a system unit runs as: its ``User=`` (root when absent); None when the name is unknown."""
from hermes_cli.gateway import _read_systemd_user_from_unit
user = _read_systemd_user_from_unit(unit_path)
if user is None:
return 0
import pwd
with contextlib.suppress(KeyError):
return pwd.getpwnam(user).pw_uid
return None
def gateway_identity(home: Path, pid: Optional[int], services: list[tuple[str, bool]]) -> tuple[Optional[int], Path]:
"""``(uid, runtime_home)`` of the gateway that serves ``home``.
uid: the live process owner; else, for a system unit, its ``User=`` — and ONLY that: a system
unit's principal is whatever systemd will run, so an unresolvable ``User=`` stays None rather than
borrowing the profile directory's owner (a stopped unit pinned to an absent NSS user is not the
account that owns the files). Without a system unit (user-scope systemd / launchd / detached), the
profile directory owner is the account the gateway runs as. None means unknown. runtime_home: the
HERMES_HOME an installed unit pins, which is where the gateway really runs; ``home`` otherwise.
"""
from hermes_cli.gateway import _hermes_home_pinned_by_unit, get_systemd_unit_path
from hermes_cli.gateway_migrate import _home_env
uid: Optional[int] = _pid_uid(pid) if pid is not None else None
runtime_home = home
has_system_unit = False
for kind, system in services:
if kind != "systemd":
continue
with _home_env(home):
unit_path = get_systemd_unit_path(system=system)
pinned = _hermes_home_pinned_by_unit(unit_path)
if pinned and runtime_home == home:
runtime_home = Path(pinned).expanduser()
if system:
has_system_unit = True
if uid is None:
uid = _system_unit_uid(unit_path)
if uid is None and not has_system_unit:
with contextlib.suppress(OSError):
uid = home.stat().st_uid
return uid, runtime_home
# --------------------------------------------------------------------------- guards
def _service_label(profile: ProfileGateway) -> str:
return profile.service_label() if profile.services else "no service manager (detached)"
def _guard_service_domain(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
"""Different manager or scope than the default gateway (system vs user systemd, launchd vs systemd,
or any service when the default is detached: the auto path never elects a secondary's manager).
Two units on one profile is an ambiguous topology the unattended path does not resolve either."""
if len(profile.services) > 1:
return (f"Profile '{profile.name}' has more than one installed service ({profile.service_label()}): "
f"an ambiguous service topology is not folded automatically.")
if set(profile.services) == set(plan.default.services):
return None
return (f"Profile '{profile.name}' runs under {_service_label(profile)} while the default gateway "
f"runs under {_service_label(plan.default)}: a different service domain is not folded automatically.")
def _guard_unix_user(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
default_uid = plan.default.uid
if default_uid is None and plan.default.has_system_unit:
# Consolidating INTO a principal this host cannot identify is the same unknown boundary
# from the other side: the default's system unit names an account NSS does not resolve.
return ("The default gateway runs a system unit whose User= cannot be resolved on this host: "
"an unknown service principal is not folded into automatically.")
if profile.uid is None and profile.has_system_unit:
# Unknown principal is not "same user": the unit names an account this host cannot resolve.
return (f"Profile '{profile.name}' runs a system unit whose User= cannot be resolved on this host: "
f"an unknown service principal is not folded automatically.")
if default_uid is None or profile.uid is None or profile.uid == default_uid:
return None
return (f"Profile '{profile.name}' runs as uid {profile.uid} while the default gateway runs as uid "
f"{default_uid}: a UNIX privilege boundary is not folded automatically.")
def _guard_home_tree(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
profiles_root = (plan.default_home / "profiles").resolve()
runtime_home = (profile.runtime_home or profile.home).resolve()
if runtime_home.is_relative_to(profiles_root):
return None
return (f"Profile '{profile.name}' runs with HERMES_HOME={runtime_home}, outside {profiles_root}: "
f"the multiplexer would serve {profile.home} instead of the live home.")
_AUTO_MIGRATION_GUARDS: tuple[Callable[[MigrationPlan, ProfileGateway], Optional[str]], ...] = (
_guard_service_domain,
_guard_unix_user,
_guard_home_tree,
)
def auto_migration_blockers(plan: MigrationPlan) -> list[str]:
"""Every boundary a standalone secondary sits behind; empty when the fleet is one user, one service
domain, one profiles/ tree — the only shape ``hermes update`` may fold on its own."""
findings = [
finding
for profile in plan.standalone_secondaries
for guard in _AUTO_MIGRATION_GUARDS
if (finding := guard(plan, profile)) is not None
]
return list(dict.fromkeys(findings)) # a default-side finding repeats per secondary
# --------------------------------------------------------------------------- opt-out
def auto_migration_opted_out(default_home: Path) -> bool:
"""``gateway.auto_multiplex_migration: false`` in the DEFAULT profile's EFFECTIVE config: the same
``load_config`` the rest of the CLI reads (``DEFAULT_CONFIG`` + config.yaml + the managed overlay), so
an administrator's managed ``false`` wins over a user's ``true`` and a YAML string ``"false"`` is
false, not truthy. Only the nested key counts, there is no top-level alias."""
from hermes_cli.config import load_config_readonly
from hermes_cli.gateway_migrate import _home_env
from utils import is_truthy_value
with _home_env(default_home):
gateway_section = load_config_readonly().get("gateway")
if not isinstance(gateway_section, dict):
return False
return not is_truthy_value(gateway_section.get("auto_multiplex_migration"), default=True)