Files
hermes-agent/tests/hermes_cli/test_serve_runtime_inventory.py
calvinnwq 074ead267d fix(update): classify a Desktop SSH serve as its remote client's, not manual-serve
The serve a remote Desktop spawns over SSH has no local spawner, so the
inventory read it as manual-serve: an update filed a manual-restart
reminder nobody on this host can discharge, reported the stale process
as unaccounted, and abort recovery could try an argv respawn without the
client's token file and owner nonce.

Classify it as desktop-ssh (using the canonical argv predicate, so rows
written before the ledger carried isolated are covered too) and treat it
like the local Desktop's own serve: skipped by the restart phase,
deferred to its client, never owed by abort recovery. A hand-started
serve --isolated stays manual-serve.
2026-09-25 12:09:37 -05:00

248 lines
11 KiB
Python

"""Serve-kind runtime inventory (#63206, campaign #91277).
A network-bound `hermes serve --host <ip>` powering a remote Desktop used to
be invisible to the update pipeline. The spawn ledger's structured launch
identity (host/port/profile, registered at serve startup) now feeds the
update inventory and the dashboard process scan.
"""
from __future__ import annotations
import sys
from dataclasses import asdict
from types import SimpleNamespace
from unittest.mock import patch
import hermes_cli.update_inventory as update_inventory
import hermes_cli.main_dashboard as main_dashboard
def _ledger_entry(**over):
entry = {
"pid": 4321,
"create_time": 111.0,
"purpose": "serve",
"install": "inst",
"spawner_pid": None,
"spawner_create": None,
"registered_at": 222.0,
"argv": "hermes serve --host 100.94.65.93 --port 9119",
"host": "100.94.65.93",
"port": 9119,
"profile": "",
}
entry.update(over)
return entry
# ---------------------------------------------------------------------------
# process_identity: structured detail round-trip
# ---------------------------------------------------------------------------
def test_register_self_records_structured_detail(tmp_path, monkeypatch):
from hermes_cli import process_identity as pi
monkeypatch.setattr(pi, "_ledger_path", lambda: tmp_path / "ledger.json")
monkeypatch.setattr(pi, "install_id", lambda *a, **k: "inst")
assert pi.register_self(
"serve", detail={"host": "100.94.65.93", "port": 9119, "profile": "work"}
)
entries = [
e
for e in pi._read_ledger(tmp_path / "ledger.json")
if e["purpose"] == "serve"
]
assert entries, "serve entry must be written"
e = entries[-1]
assert e["host"] == "100.94.65.93"
assert e["port"] == 9119
assert e["profile"] == "work"
def test_register_self_records_isolated_marker(tmp_path, monkeypatch):
from hermes_cli import process_identity as pi
monkeypatch.setattr(pi, "_ledger_path", lambda: tmp_path / "ledger.json")
monkeypatch.setattr(pi, "install_id", lambda *a, **k: "inst")
assert pi.register_self("serve", detail={"host": "127.0.0.1", "port": 9119, "isolated": True})
assert pi._read_ledger(tmp_path / "ledger.json")[-1]["isolated"] is True
def test_register_self_without_detail_stays_backward_compatible(
tmp_path, monkeypatch
):
from hermes_cli import process_identity as pi
monkeypatch.setattr(pi, "_ledger_path", lambda: tmp_path / "ledger.json")
monkeypatch.setattr(pi, "install_id", lambda *a, **k: "inst")
assert pi.register_self("gateway")
e = pi._read_ledger(tmp_path / "ledger.json")[-1]
assert e["host"] == "" and e["port"] is None and e["profile"] == ""
# ---------------------------------------------------------------------------
# update_inventory: serve collector
# ---------------------------------------------------------------------------
def test_inventory_includes_manual_serve_from_ledger(monkeypatch):
entry = _ledger_entry()
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [entry],
spawner_is_dead=lambda e: None, # no spawner recorded → manual
)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
plan = update_inventory.collect_runtime_inventory()
serves = [r for r in plan.runtimes if r.kind == "serve"]
assert serves, "manual serve must appear in the inventory"
row = serves[0]
assert row.pid == 4321
assert row.supervisor == "manual-serve"
assert row.restart_via == "respawn-argv"
assert row.detail["host"] == "100.94.65.93"
assert row.detail["port"] == 9119
def test_inventory_classifies_desktop_owned_serve(monkeypatch):
entry = _ledger_entry(spawner_pid=999, spawner_create=1.0)
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [entry],
spawner_is_dead=lambda e: False, # Electron parent alive
)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
plan = update_inventory.collect_runtime_inventory()
serves = [r for r in plan.runtimes if r.kind == "serve"]
assert serves and serves[0].supervisor == "desktop"
assert serves[0].restart_via == "desktop"
_SSH_ARGV = ("hermes serve --isolated --host 127.0.0.1 --port 0 "
"--ssh-session-token-file /h/.hermes/desktop-ssh/a/b.token --ssh-owner-nonce 0123456789abcdef")
def test_inventory_classifies_remote_desktop_ssh_serve_as_its_clients(monkeypatch):
"""A serve another machine's Desktop spawned over SSH has no local spawner, so the spawner
probe alone reads ``manual-serve``: the update then files a manual-restart reminder nobody on
this host can discharge, and the recovery pass may try an argv respawn of a process whose
token file and owner nonce only its remote client holds. The remote client owns its restart."""
from hermes_cli.update_serve_obligations import defer_manual_serve
entry = _ledger_entry(argv=_SSH_ARGV, host="127.0.0.1", port=57474, isolated=True)
fake_pi = SimpleNamespace(ledger_entries=lambda **k: [entry], spawner_is_dead=lambda e: None)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
plan = update_inventory.collect_runtime_inventory()
row = next(r for r in plan.runtimes if r.kind == "serve")
assert row.supervisor not in ("manual-serve", "desktop")
assert row.restart_via != "respawn-argv"
monkeypatch.undo() # real process_identity: no durable manual-restart reminder may be filed
assert defer_manual_serve(asdict(row)) is False
def test_hand_started_isolated_serve_stays_manual(monkeypatch):
"""``--isolated`` alone is an opt-out of the host singleton, not remote ownership: a user's own
``hermes serve --isolated`` keeps its manual-serve relaunch."""
entry = _ledger_entry(argv="hermes serve --isolated --host 127.0.0.1 --port 9119", isolated=True)
fake_pi = SimpleNamespace(ledger_entries=lambda **k: [entry], spawner_is_dead=lambda e: None)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
row = next(r for r in update_inventory.collect_runtime_inventory().runtimes if r.kind == "serve")
assert row.supervisor == "manual-serve"
def test_stale_remote_desktop_ssh_serve_is_deferred_to_its_client_not_unaccounted(monkeypatch):
"""Still on pre-update code after the update, the SSH serve is its remote client's to recycle:
reported as deferred (not an unaccounted failure that fails the update), and never owed by the
abort-recovery pass."""
from hermes_cli.update_abort_recovery import _owed_stale_serve_rows
entry = _ledger_entry(argv=_SSH_ARGV, host="127.0.0.1", port=57474, isolated=True)
fake_pi = SimpleNamespace(ledger_entries=lambda **k: [entry], spawner_is_dead=lambda e: None)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
plan = update_inventory.collect_runtime_inventory()
outcomes = update_inventory.match_runtime_outcomes(
plan, restarted_services=[], relaunched_profiles=[], externally_supervised_profiles=[],
killed_pids=set(), failed_units=[], stale_serve_pids={entry["pid"]},
)
serve = next(o for o in outcomes if o["kind"] == "serve")
assert serve["outcome"] == "deferred"
assert update_inventory.report_unaccounted_runtimes(outcomes) is False
row = next(r for r in plan.runtimes if r.kind == "serve")
assert _owed_stale_serve_rows([{"supervisor": row.supervisor}]) == []
# ---------------------------------------------------------------------------
# dashboard_procs: ledger augmentation of the scan (#81564 half)
# ---------------------------------------------------------------------------
def test_scan_dashboard_processes_includes_ledger_only_serves(monkeypatch):
"""A profiled serve (`hermes --profile p serve ...`) matches no scan
pattern; the ledger row must still surface it."""
import hermes_cli.dashboard_procs as dp
profiled = _ledger_entry(
pid=8123,
argv="hermes --profile work serve --host 100.94.65.93 --port 9119",
profile="work",
)
fake_pi = SimpleNamespace(ledger_entries=lambda **k: [profiled])
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
# Force the ps/wmic scan itself to find nothing.
fake_run = SimpleNamespace(returncode=0, stdout="")
monkeypatch.setattr(
dp.subprocess, "run", lambda *a, **k: fake_run
)
result = dp._scan_dashboard_processes()
assert (8123, profiled["argv"]) in result
def test_scan_dashboard_processes_ledger_respects_exclusions(monkeypatch):
import hermes_cli.dashboard_procs as dp
entry = _ledger_entry(pid=8124)
fake_pi = SimpleNamespace(ledger_entries=lambda **k: [entry])
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
fake_run = SimpleNamespace(returncode=0, stdout="")
monkeypatch.setattr(dp.subprocess, "run", lambda *a, **k: fake_run)
assert dp._scan_dashboard_processes(exclude_pids={8124}) == []
def test_inventory_records_the_serve_process_incarnation(monkeypatch):
"""The plan carries ``(pid, create_time)``, not just the PID (#92145 review).
The post-abort survivor probe compares a planned serve against the live
spawn ledger. With only the number to compare, a NEW serve that reused the
old PID reads as the pre-update process that never restarted, and recovery
stays incomplete forever.
"""
entry = _ledger_entry(create_time=1712345678.5)
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [entry],
spawner_is_dead=lambda e: None,
)
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
plan = update_inventory.collect_runtime_inventory()
serves = [r for r in plan.runtimes if r.kind == "serve"]
assert serves and serves[0].detail["create_time"] == 1712345678.5
# ---------------------------------------------------------------------------
# update_inventory: launchd-owned serve/dashboard classification (#116503)
# ---------------------------------------------------------------------------
def test_inventory_classifies_launchd_job_owned_serve(monkeypatch):
"""A KeepAlive LaunchAgent backend's recorded spawner (the bootstrap shell) is long dead,
so the spawner probe alone reads manual-serve — and the update plan then restarts it as a
detached argv respawn that fights the job's own KeepAlive respawn. A loaded job whose
ProgramArguments match the ledger argv must classify the row launchd (kickstart restart)."""
entry = _ledger_entry(spawner_pid=999, spawner_create=1.0)
fake_pi = SimpleNamespace(
ledger_entries=lambda **k: [entry],
spawner_is_dead=lambda e: True, # bootstrap shell provably gone
)
jobs = [("gui/501", "ai.hermes.dashboard",
["hermes", "serve", "--host", "100.94.65.93", "--port", "9119"], None)]
monkeypatch.setitem(sys.modules, "hermes_cli.process_identity", fake_pi)
with patch.object(main_dashboard, "_loaded_launchd_backend_jobs", return_value=jobs), \
patch("hermes_cli.dashboard_procs._process_ancestors", return_value=[]):
plan = update_inventory.collect_runtime_inventory()
serves = [r for r in plan.runtimes if r.kind == "serve"]
assert serves, "launchd-owned serve must appear in the inventory"
row = serves[0]
assert row.supervisor == "launchd"
assert row.restart_via == "launchd"
assert row.detail["launchd_domain"] == "gui/501"
assert row.detail["launchd_label"] == "ai.hermes.dashboard"