test: restore gateway lifecycle and doctor TCC guards dropped by #120071
Restored (OS fakes replaced by real windows_only/macos_only markers): - test_doctor.py: macOS TCC grant check — silent off macOS / without bundle, warns on cdhash-pinned DR, stable for identifier and certificate-anchored DRs, never claims stable when codesign output is unreadable (#86385, #73681). - test_gateway.py: Windows stop drains before force-kill, force-kills only after drain timeout, kill carries pre-drain start time so a recycled PID is refused (#112750); orphan reaper spares the launchd gateway (#74075), the recorded PID and Scheduled Task bootstrap parent (#86098), even when validation fails (#87158). - test_gateway_proc_fallback.py: ps fallback uses BSD-compatible flags/columns (#74075); launchd PID collection for current and all profiles (#73627). - test_windows_gateway_cold_start_desktop_lifecycle.py: dead attested profiles cold-start beside a running one / when nothing runs; service-supervised running profile is not cold-started (#110959).
This commit is contained in:
@@ -1373,12 +1373,82 @@ class TestDoctorDeprecatedConfigAndEnv:
|
||||
assert doctor_config.collect_deprecated_env_vars(None) == []
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_macos_tcc_grant_check_is_silent_off_macos(monkeypatch, capsys, tmp_path):
|
||||
"""Off macOS the TCC check prints nothing, even with a bundle present."""
|
||||
monkeypatch.setattr(doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app")
|
||||
doctor_platform.check_macos_tcc_grants()
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
@pytest.mark.macos_only
|
||||
class TestMacOSTCCGrants:
|
||||
"""macOS TCC grant persistence check (#86385): a cdhash-pinned DR (pre-#73681
|
||||
local builds) silently resets Screen Recording/Accessibility grants on every
|
||||
rebuild while the Settings toggle stays ON."""
|
||||
|
||||
@staticmethod
|
||||
def _darwin_bundle(monkeypatch, tmp_path, dr):
|
||||
monkeypatch.setattr(doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app")
|
||||
if dr is not ...:
|
||||
monkeypatch.setattr(doctor_platform, "_macos_desktop_dr", lambda app: dr)
|
||||
|
||||
def test_silent_without_desktop_bundle(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(doctor_platform, "_desktop_app_bundle", lambda: None)
|
||||
doctor_platform.check_macos_tcc_grants()
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
def test_warns_on_cdhash_pinned_dr(self, monkeypatch, capsys, tmp_path):
|
||||
self._darwin_bundle(
|
||||
monkeypatch, tmp_path,
|
||||
'designated => identifier "com.nousresearch.hermes" and cdhash H"97e692f3890f781fa0ad5ad6cb9d769cfaf42628"',
|
||||
)
|
||||
doctor_platform.check_macos_tcc_grants()
|
||||
out = capsys.readouterr().out
|
||||
assert "TCC grants will reset after every update" in out
|
||||
assert "hermes update" in out
|
||||
assert "signing identity is stable" not in out
|
||||
|
||||
def test_identifier_dr_is_stable_with_upgrade_hint_and_repair_info(self, monkeypatch, capsys, tmp_path):
|
||||
self._darwin_bundle(monkeypatch, tmp_path, 'designated => identifier "com.nousresearch.hermes"')
|
||||
doctor_platform.check_macos_tcc_grants()
|
||||
out = capsys.readouterr().out
|
||||
assert "TCC signing identity is stable" in out
|
||||
assert "--setup-tcc-identity" in out
|
||||
assert "tccutil reset ScreenCapture com.nousresearch.hermes" in out
|
||||
|
||||
def test_certificate_anchored_dr_is_stable_without_upgrade_hint(self, monkeypatch, capsys, tmp_path):
|
||||
self._darwin_bundle(
|
||||
monkeypatch, tmp_path,
|
||||
'designated => identifier "com.nousresearch.hermes" and certificate root = H"aabbcc"',
|
||||
)
|
||||
doctor_platform.check_macos_tcc_grants()
|
||||
out = capsys.readouterr().out
|
||||
assert "TCC signing identity is stable" in out
|
||||
assert "--setup-tcc-identity" not in out
|
||||
assert "tccutil reset ScreenCapture com.nousresearch.hermes" in out
|
||||
|
||||
@pytest.mark.parametrize("failure", ["none", "empty", "timeout", "no_codesign"])
|
||||
def test_unreadable_dr_warns_and_never_claims_stable(self, monkeypatch, capsys, tmp_path, failure):
|
||||
"""codesign failing, hanging, missing or printing nothing degrades to a
|
||||
warning; an empty DR must not false-positive as a stable identity."""
|
||||
if failure in ("none", "empty"):
|
||||
self._darwin_bundle(monkeypatch, tmp_path, None if failure == "none" else "")
|
||||
else:
|
||||
self._darwin_bundle(monkeypatch, tmp_path, ...)
|
||||
if failure == "timeout":
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/codesign")
|
||||
|
||||
def _timeout(*args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd=["codesign"], timeout=15)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", _timeout)
|
||||
else:
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: None)
|
||||
doctor_platform.check_macos_tcc_grants()
|
||||
out = capsys.readouterr().out
|
||||
assert "could not read code-signing requirement" in out
|
||||
assert "stable" not in out
|
||||
|
||||
|
||||
def test_run_doctor_reports_shadowed_lightpanda_engine(monkeypatch, tmp_path):
|
||||
|
||||
@@ -503,7 +503,98 @@ class TestRestartWaitsForApiServerPort:
|
||||
|
||||
|
||||
class TestStopProfileGateway:
|
||||
@pytest.mark.windows_only
|
||||
def test_windows_stop_drains_marker_before_force_termination(self, monkeypatch):
|
||||
"""Windows must let the marker watcher run before escalating (#112750)."""
|
||||
import hermes_cli.gateway_windows as gateway_windows
|
||||
|
||||
pid = 12345
|
||||
calls = []
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: pid)
|
||||
monkeypatch.setattr(gateway_windows, "_windows_stop_drain_timeout", lambda: 7.0)
|
||||
monkeypatch.setattr(
|
||||
gateway_windows,
|
||||
"_drain_gateway_pid",
|
||||
lambda target, timeout: calls.append(("drain", target, timeout)) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_windows,
|
||||
"_force_terminate_known_gateway_pids",
|
||||
lambda pids: calls.append(("force", pids)),
|
||||
)
|
||||
monkeypatch.setattr(gateway.os, "kill", lambda *_: calls.append(("kill",)))
|
||||
monkeypatch.setattr("gateway.status._pid_exists", lambda target: False)
|
||||
monkeypatch.setattr("gateway.status.remove_pid_file", lambda: None)
|
||||
monkeypatch.setattr(gateway, "_reap_unsupervised_gateway_orphans", lambda **_: False)
|
||||
|
||||
assert gateway.stop_profile_gateway() is True
|
||||
assert calls == [("drain", pid, 7.0)]
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_windows_stop_force_terminates_only_after_drain_timeout(self, monkeypatch):
|
||||
"""A wedged Windows gateway still has a bounded force-stop fallback (#112750)."""
|
||||
import hermes_cli.gateway_windows as gateway_windows
|
||||
|
||||
pid = 12345
|
||||
calls = []
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: pid)
|
||||
monkeypatch.setattr(gateway_windows, "_windows_stop_drain_timeout", lambda: 7.0)
|
||||
monkeypatch.setattr("gateway.status.get_process_start_time", lambda target: 100)
|
||||
monkeypatch.setattr(
|
||||
gateway_windows,
|
||||
"_drain_gateway_pid",
|
||||
lambda target, timeout: calls.append(("drain", target, timeout)) or False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_windows,
|
||||
"_force_terminate_known_gateway_pids",
|
||||
lambda pids: calls.append(("force", pids)),
|
||||
)
|
||||
monkeypatch.setattr(gateway.os, "kill", lambda *_: calls.append(("kill",)))
|
||||
monkeypatch.setattr("gateway.status._pid_exists", lambda target: False)
|
||||
monkeypatch.setattr("gateway.status.remove_pid_file", lambda: None)
|
||||
monkeypatch.setattr(gateway, "_reap_unsupervised_gateway_orphans", lambda **_: False)
|
||||
|
||||
assert gateway.stop_profile_gateway() is True
|
||||
assert calls == [("drain", pid, 7.0), ("force", {pid: 100})]
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_windows_stop_force_kill_carries_pre_drain_identity(self, monkeypatch):
|
||||
"""The post-drain taskkill must be guarded by the start time captured BEFORE the <=30 s drain:
|
||||
a PID recycled during the wait shows a different start time, so ``terminate_pid``'s mismatch
|
||||
refusal fires instead of killing an unrelated process. Reading it at kill time is a vacuous
|
||||
self-comparison."""
|
||||
import gateway.status as status
|
||||
import hermes_cli.gateway_windows as gateway_windows
|
||||
|
||||
pid = 4242
|
||||
calls = []
|
||||
clock = {"now": 0.0}
|
||||
alive = {"value": True}
|
||||
|
||||
monkeypatch.setattr(gateway_windows.time, "monotonic", lambda: clock["now"])
|
||||
monkeypatch.setattr(
|
||||
gateway_windows.time, "sleep", lambda _s: clock.__setitem__("now", clock["now"] + 10.0)
|
||||
)
|
||||
monkeypatch.setattr(gateway_windows, "_windows_stop_drain_timeout", lambda: 7.0)
|
||||
monkeypatch.setattr(status, "get_running_pid", lambda: pid if alive["value"] else None)
|
||||
monkeypatch.setattr(status, "write_planned_stop_marker", lambda target: None)
|
||||
monkeypatch.setattr(status, "_pid_exists", lambda target: alive["value"])
|
||||
# The original gateway (start time 100) exits during the drain and its PID is recycled (999).
|
||||
monkeypatch.setattr(
|
||||
status, "get_process_start_time", lambda target: 100 if clock["now"] < 7.0 else 999
|
||||
)
|
||||
|
||||
def _terminate(target, force=False, expected_start_time=None):
|
||||
calls.append((target, force, expected_start_time))
|
||||
alive["value"] = False
|
||||
|
||||
monkeypatch.setattr(status, "terminate_pid", _terminate)
|
||||
monkeypatch.setattr(status, "remove_pid_file", lambda: None)
|
||||
monkeypatch.setattr(gateway, "_reap_unsupervised_gateway_orphans", lambda **_: False)
|
||||
|
||||
assert gateway.stop_profile_gateway() is True
|
||||
assert calls == [(pid, True, 100)]
|
||||
|
||||
def test_stop_profile_gateway_keeps_pid_file_when_process_still_running(self, monkeypatch):
|
||||
calls = {"kill": 0, "alive_probes": 0, "remove": 0, "reap_calls": 0}
|
||||
@@ -563,6 +654,179 @@ class TestStopProfileGateway:
|
||||
assert killed_pid in reap_extra_excludes[0]
|
||||
|
||||
|
||||
@pytest.mark.macos_only
|
||||
class TestReapUnsupervisedGatewayOrphansMacOS:
|
||||
"""Tests that the orphan reaper excludes launchd-managed PIDs on macOS.
|
||||
|
||||
Regression guard: without the ``is_macos()`` exclusion of
|
||||
``_get_service_pids()``, the reaper would SIGTERM the launchd-supervised
|
||||
gateway every time Hermes Desktop opens (``hermes serve`` calls
|
||||
``_reap_unsupervised_gateway_orphans`` during startup).
|
||||
"""
|
||||
|
||||
def test_macos_excludes_launchd_pid_from_kill(self, monkeypatch):
|
||||
"""A launchd-managed PID must not appear in the orphan kill list."""
|
||||
launchd_pid = 52615
|
||||
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
|
||||
# _get_service_pids returns the launchd-managed gateway PID.
|
||||
# (accepts all_profiles: the reaper asks for the whole fleet, #74075)
|
||||
monkeypatch.setattr(
|
||||
gateway, "_get_service_pids", lambda all_profiles=False: {launchd_pid}
|
||||
)
|
||||
# No pidfile-recorded gateway in this scenario.
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: None)
|
||||
|
||||
# find_gateway_pids returns the launchd PID plus a real orphan.
|
||||
# The reaper should only kill the orphan, not the launchd PID.
|
||||
orphan_pid = 99998
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"find_gateway_pids",
|
||||
lambda exclude_pids=None: [p for p in [launchd_pid, orphan_pid] if p not in (exclude_pids or set())],
|
||||
)
|
||||
|
||||
killed_pids = []
|
||||
monkeypatch.setattr(gateway.os, "kill", lambda pid, sig: killed_pids.append((pid, sig)))
|
||||
monkeypatch.setattr("gateway.status._pid_exists", lambda pid: False)
|
||||
monkeypatch.setattr("gateway.status.write_planned_stop_marker", lambda pid: None)
|
||||
monkeypatch.setattr("time.sleep", lambda _: None)
|
||||
monkeypatch.setattr("time.monotonic", lambda: 1.0)
|
||||
|
||||
result = gateway._reap_unsupervised_gateway_orphans()
|
||||
|
||||
assert result is True # at least one orphan was reaped
|
||||
killed = [pid for pid, _ in killed_pids]
|
||||
assert orphan_pid in killed # the real orphan was killed
|
||||
assert launchd_pid not in killed # the launchd PID was NOT killed
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
class TestReapUnsupervisedGatewayOrphansWindows:
|
||||
"""Tests that the orphan reaper spares the recorded gateway PID and its
|
||||
supervision chain on Windows.
|
||||
|
||||
Regression guard: without the Windows exemption of the recorded healthy
|
||||
gateway PID (and its parent chain), the reaper would SIGTERM/SIGKILL a
|
||||
Scheduled-Task-supervised gateway every time Hermes Desktop opens
|
||||
(``hermes serve`` calls ``_reap_unsupervised_gateway_orphans`` during
|
||||
startup). The Scheduled-Task bootstrap's argv matches the gateway scan,
|
||||
so it is reaped as an "orphan" — and when the bootstrap dies, the
|
||||
detached gateway it spawned exits with it (#86098).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _install_fake_psutil(monkeypatch, chain):
|
||||
"""Install a fake psutil module exposing the given process chain."""
|
||||
by_pid = {proc.pid: proc for proc in chain}
|
||||
fake_psutil = SimpleNamespace(Process=lambda pid: by_pid[pid])
|
||||
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
||||
|
||||
def test_windows_excludes_recorded_pid_and_bootstrap_from_kill(self, monkeypatch):
|
||||
"""The recorded gateway PID and its bootstrap parent must not be killed."""
|
||||
recorded_pid = 52615 # detached gateway recorded in gateway.pid
|
||||
bootstrap_pid = 52616 # Scheduled-Task bootstrap (argv matches scan)
|
||||
orphan_pid = 99998 # a real orphan that should still be reaped
|
||||
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
|
||||
# gateway.pid records the detached gateway; its parent is the
|
||||
# Scheduled-Task bootstrap whose argv matches the gateway scan.
|
||||
bootstrap = SimpleNamespace(pid=bootstrap_pid, parent=lambda: None)
|
||||
recorded = SimpleNamespace(pid=recorded_pid, parent=lambda: bootstrap)
|
||||
self._install_fake_psutil(monkeypatch, [recorded, bootstrap])
|
||||
|
||||
# get_running_pid() returns the recorded healthy gateway PID.
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.get_running_pid", lambda cleanup_stale=True: recorded_pid
|
||||
)
|
||||
|
||||
# find_gateway_pids returns the recorded PID, its bootstrap parent
|
||||
# and a real orphan. The reaper should only kill the orphan.
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"find_gateway_pids",
|
||||
lambda exclude_pids=None: [
|
||||
p
|
||||
for p in [recorded_pid, bootstrap_pid, orphan_pid]
|
||||
if p not in (exclude_pids or set())
|
||||
],
|
||||
)
|
||||
|
||||
killed_pids = []
|
||||
marked_pids = []
|
||||
monkeypatch.setattr(gateway.os, "kill", lambda pid, sig: killed_pids.append((pid, sig)))
|
||||
monkeypatch.setattr("gateway.status._pid_exists", lambda pid: False)
|
||||
monkeypatch.setattr("gateway.status.write_planned_stop_marker", marked_pids.append)
|
||||
monkeypatch.setattr("time.sleep", lambda _: None)
|
||||
monkeypatch.setattr("time.monotonic", lambda: 1.0)
|
||||
|
||||
result = gateway._reap_unsupervised_gateway_orphans()
|
||||
|
||||
assert result is True # at least one orphan was reaped
|
||||
assert marked_pids == [orphan_pid] # the real orphan was asked to drain
|
||||
assert killed_pids == [] # Windows SIGTERM must not TerminateProcess
|
||||
|
||||
def test_windows_raw_record_supplies_exclusion_when_validation_fails(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""A registration that fails liveness VALIDATION must still shield
|
||||
the recorded PID from the sweep.
|
||||
|
||||
get_running_pid returns None whenever the record fails validation
|
||||
(start-time mismatch, argv drift, lock hiccup) — regardless of
|
||||
cleanup_stale, which only controls unlinking. The exclusion set is
|
||||
therefore built from the RAW pidfile/lock records: a stale recorded
|
||||
PID at worst spares one process, while a validation false-negative
|
||||
would TerminateProcess a healthy standalone gateway (#87158).
|
||||
"""
|
||||
recorded_pid = 52615
|
||||
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
|
||||
recorded = SimpleNamespace(pid=recorded_pid, parent=lambda: None)
|
||||
self._install_fake_psutil(monkeypatch, [recorded])
|
||||
|
||||
# The raw record is present on disk; the validated probe rejects it.
|
||||
monkeypatch.setattr(
|
||||
"gateway.status._read_pid_record", lambda *a, **k: {"pid": recorded_pid}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status._read_gateway_lock_record", lambda *a, **k: None
|
||||
)
|
||||
probe_kwargs = []
|
||||
|
||||
def failing_validation(*a, cleanup_stale=True, **k):
|
||||
probe_kwargs.append(cleanup_stale)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.status.get_running_pid", failing_validation
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"find_gateway_pids",
|
||||
lambda exclude_pids=None: [
|
||||
p for p in [recorded_pid] if p not in (exclude_pids or set())
|
||||
],
|
||||
)
|
||||
|
||||
killed_pids = []
|
||||
monkeypatch.setattr(
|
||||
gateway.os, "kill", lambda pid, sig: killed_pids.append((pid, sig))
|
||||
)
|
||||
|
||||
result = gateway._reap_unsupervised_gateway_orphans()
|
||||
|
||||
assert probe_kwargs == [False], (
|
||||
"the fallback probe must still be non-destructive so the sweep "
|
||||
f"never unlinks the record it just read, got {probe_kwargs}"
|
||||
)
|
||||
assert result is False
|
||||
assert killed_pids == [] # the standalone gateway survived
|
||||
|
||||
|
||||
class TestReaperCandidateIsSupervisorOwned:
|
||||
"""Regression for the Windows pidfile-less supervisor-owned case (#83683).
|
||||
|
||||
|
||||
@@ -114,14 +114,126 @@ class TestProcFallback:
|
||||
mock_ps.assert_not_called() # /proc dir existed, so ps not called
|
||||
|
||||
|
||||
class TestPsFallbackBsdCompat:
|
||||
"""The ps fallback must use flags BSD/macOS ps accepts (#73626, #74075).
|
||||
|
||||
``ps -A eww`` fails on macOS (BSD ``e`` is not the procps flag), which
|
||||
made gateway discovery silently return nothing whenever /proc is absent.
|
||||
"""
|
||||
|
||||
def test_ps_fallback_uses_bsd_compatible_flags_and_columns(self):
|
||||
with (
|
||||
patch("hermes_cli.gateway.is_windows", return_value=False),
|
||||
patch("os.path.isdir", side_effect=lambda p: p != "/proc"),
|
||||
patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||
assert not gateway_mod._scan_gateway_pids(set())
|
||||
|
||||
ps_calls = [
|
||||
c[0][0] for c in mock_run.call_args_list if c[0] and c[0][0] and c[0][0][0] == "ps"
|
||||
]
|
||||
assert ps_calls, "ps was not invoked at all"
|
||||
ps_call = ps_calls[0]
|
||||
assert "-Aww" in ps_call and "eww" not in " ".join(ps_call), ps_call
|
||||
assert "-o" in ps_call and "pid=,command=" in ps_call, ps_call
|
||||
|
||||
|
||||
class TestGetServicePidsAllProfiles:
|
||||
"""_get_service_pids(all_profiles=...) discovery across profiles."""
|
||||
|
||||
@pytest.mark.macos_only
|
||||
def test_default_scope_uses_current_profile_label(self):
|
||||
"""Without all_profiles, only the current profile's launchd agent is
|
||||
located (per-label domain-explicit probe, #73627)."""
|
||||
located = []
|
||||
|
||||
def _fake_locate(label):
|
||||
located.append(label)
|
||||
return ("gui/501", 123)
|
||||
|
||||
with (
|
||||
patch("hermes_cli.gateway.supports_systemd_services", return_value=False),
|
||||
patch(
|
||||
"hermes_cli.gateway.get_launchd_label",
|
||||
return_value="ai.hermes.gateway.myprofile",
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.gateway._locate_launchd_gateway_service",
|
||||
side_effect=_fake_locate,
|
||||
),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
pids = gateway_mod._get_service_pids()
|
||||
|
||||
assert pids == {123}
|
||||
# Default scope: exactly the current profile's label, no fleet
|
||||
# enumeration and no bare `launchctl list` scan.
|
||||
assert located == ["ai.hermes.gateway.myprofile"]
|
||||
launchctl_calls = [
|
||||
c[0][0]
|
||||
for c in mock_run.call_args_list
|
||||
if c[0] and c[0][0] and c[0][0][0] == "launchctl"
|
||||
]
|
||||
assert launchctl_calls == []
|
||||
|
||||
@pytest.mark.macos_only
|
||||
def test_all_profiles_enumerates_all_gateway_labels(self):
|
||||
"""With all_profiles=True, every install-derived gateway label is
|
||||
located (#73627), and the bare ``launchctl list`` prefix scan still
|
||||
widens the EXCLUDE set with unmapped ai.hermes.gateway* agents
|
||||
(#74075 belt-and-suspenders)."""
|
||||
located = []
|
||||
label_pids = {
|
||||
"ai.hermes.gateway": 123,
|
||||
"ai.hermes.gateway-profile-b": 456,
|
||||
}
|
||||
|
||||
def _fake_locate(label):
|
||||
located.append(label)
|
||||
pid = label_pids.get(label)
|
||||
return ("gui/501", pid) if pid else (None, None)
|
||||
|
||||
with (
|
||||
patch("hermes_cli.gateway.supports_systemd_services", return_value=False),
|
||||
patch(
|
||||
"hermes_cli.gateway.get_launchd_label",
|
||||
return_value="ai.hermes.gateway",
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.gateway.launchd_gateway_labels_for_install",
|
||||
return_value=["ai.hermes.gateway", "ai.hermes.gateway-profile-b"],
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.gateway._locate_launchd_gateway_service",
|
||||
side_effect=_fake_locate,
|
||||
),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"999\t0\tai.hermes.gateway-unmapped\n"
|
||||
"789\t0\tcom.apple.some.other.agent\n"
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
pids = gateway_mod._get_service_pids(all_profiles=True)
|
||||
|
||||
# Label-derived fleet + prefix-scan stragglers; non-gateway excluded.
|
||||
assert pids == {123, 456, 999}
|
||||
assert 789 not in pids
|
||||
assert sorted(located) == [
|
||||
"ai.hermes.gateway",
|
||||
"ai.hermes.gateway-profile-b",
|
||||
]
|
||||
launchctl_calls = [
|
||||
c[0][0]
|
||||
for c in mock_run.call_args_list
|
||||
if c[0] and c[0][0] and c[0][0][0] == "launchctl"
|
||||
]
|
||||
assert launchctl_calls == [["launchctl", "list"]]
|
||||
|
||||
def test_all_profiles_preserves_systemd_behavior(self):
|
||||
"""systemd scope is unaffected by the all_profiles switch — it already
|
||||
|
||||
@@ -18,10 +18,16 @@ does not restart the messaging gateway itself.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import gateway as hermes_gateway
|
||||
from hermes_cli import gateway_windows
|
||||
from hermes_cli import main as cli_main
|
||||
from hermes_cli import process_identity
|
||||
from hermes_cli import update_cmd
|
||||
import hermes_cli.update_cmd_windows as update_cmd_windows
|
||||
|
||||
|
||||
def _live_serve_ledger_entry() -> dict:
|
||||
@@ -90,3 +96,114 @@ def test_orphaned_control_plane_does_not_own_lifecycle(monkeypatch):
|
||||
assert update_cmd._desktop_owns_gateway_lifecycle() is False
|
||||
|
||||
|
||||
def _running_beta_pause_fixture(monkeypatch, tmp_path):
|
||||
"""Windows update with ``beta`` (PID 777) running and the default profile home at ``tmp_path``."""
|
||||
from types import SimpleNamespace
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
|
||||
homes = {"default": tmp_path, "beta": tmp_path / "profiles" / "beta"}
|
||||
homes["beta"].mkdir(parents=True)
|
||||
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: True)
|
||||
monkeypatch.setattr(update_cmd_windows, "_desktop_owns_gateway_lifecycle", lambda: True)
|
||||
beta = SimpleNamespace(pid=777, profile="beta")
|
||||
monkeypatch.setattr(update_cmd_windows, "_discover_windows_gateways", lambda: ({777: beta}, [], set(), [777]))
|
||||
monkeypatch.setattr(update_cmd_windows, "_request_socket_pauses", lambda *a: ({"beta": 777}, [777], []))
|
||||
monkeypatch.setattr(cli_main, "_venv_launcher_ancestors", lambda pids: [])
|
||||
monkeypatch.setattr(cli_main, "_wait_for_windows_update_gateway_exit", lambda pids, timeout: set())
|
||||
monkeypatch.setattr(profiles_mod, "get_active_profile_name", lambda: "default")
|
||||
monkeypatch.setattr(profiles_mod, "profiles_to_serve", lambda multiplex, **_kw: [(n, h) for n, h in homes.items() if n in ("default", "beta")])
|
||||
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda name: homes[name])
|
||||
# Resume side.
|
||||
monkeypatch.setattr(cli_main, "_refresh_windows_gateway_launchers", lambda: None)
|
||||
monkeypatch.setattr(hermes_gateway, "launch_detached_profile_gateway_restart", lambda p, o: True)
|
||||
ready_probes: list = []
|
||||
monkeypatch.setattr(gateway_windows, "_wait_for_gateway_ready", lambda *a, **k: ready_probes.append(k) or [4242])
|
||||
homes["_ready_probes"] = ready_probes
|
||||
return homes
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_dead_attested_default_is_cold_started_beside_running_beta(monkeypatch, tmp_path, capsys):
|
||||
"""#110959: the all-or-nothing plan never ran while ``beta`` was alive, so a default gateway
|
||||
that died after a ✓ stayed down after the update. Its dead attestation must become a per-profile
|
||||
cold-start obligation on the token; resume spawns it under the default home and consumes only
|
||||
that generation, while ``beta`` still goes through the ordinary relaunch."""
|
||||
homes = _running_beta_pause_fixture(monkeypatch, tmp_path)
|
||||
gateway_windows._write_start_attestation([555], "direct spawn (PID 555)")
|
||||
marker = tmp_path / "state" / "gateway.start-attestation.json"
|
||||
generation = json.loads(marker.read_text(encoding="utf-8"))["generation"]
|
||||
|
||||
token = update_cmd._pause_windows_gateways_for_update()
|
||||
|
||||
assert token["profiles"] == {"beta": 777}
|
||||
assert token["cold_start_profiles"] == {"default": generation}
|
||||
assert marker.exists() # plan-time probe is read-only
|
||||
|
||||
spawned = []
|
||||
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda **k: spawned.append(k) or 4242)
|
||||
update_cmd._resume_windows_gateways_after_update(token)
|
||||
|
||||
assert spawned == [{"home": homes["default"]}]
|
||||
# Readiness is probed in the default profile's own home: live ``beta`` must not vouch for it.
|
||||
assert {"home": homes["default"]} in homes["_ready_probes"]
|
||||
# The authorizing generation is consumed and the NEW PID is attested in the same profile home,
|
||||
# so a death after this CLI exits stays visible to the next update.
|
||||
reattested = json.loads(marker.read_text(encoding="utf-8"))
|
||||
assert (reattested["pids"], reattested["generation"] != generation) == ([4242], True)
|
||||
assert "cold_start_profiles" not in token
|
||||
assert token["relaunched_profiles"] == ["beta"]
|
||||
assert token["resume_needed"] is False
|
||||
out = capsys.readouterr().out
|
||||
assert "Gateway profile default started via cold-start after update (PID: 4242)" in out
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_every_dead_attested_profile_is_cold_started_when_nothing_runs(monkeypatch, tmp_path):
|
||||
"""Nothing running, active profile exited cleanly (plan → None), ``beta`` dead-attested: beta still
|
||||
gets a token and a spawn. And when BOTH owe a spawn, the fleet-wide active cold-start runs FIRST —
|
||||
a beta spawned earlier would satisfy its any-live-gateway guard and the active profile would stay down."""
|
||||
homes = _running_beta_pause_fixture(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(update_cmd_windows, "_discover_windows_gateways", lambda: ({}, [], set(), []))
|
||||
monkeypatch.setattr(update_cmd_windows, "_windows_cold_start_plan", lambda: None)
|
||||
gateway_windows._write_start_attestation([556], "direct spawn (PID 556)", home=homes["beta"])
|
||||
beta_marker = homes["beta"] / "state" / "gateway.start-attestation.json"
|
||||
beta_generation = json.loads(beta_marker.read_text(encoding="utf-8"))["generation"]
|
||||
|
||||
token = update_cmd._pause_windows_gateways_for_update()
|
||||
assert token["cold_start_profiles"] == {"beta": beta_generation}
|
||||
assert token["profiles"] == {}
|
||||
|
||||
order = []
|
||||
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda **k: order.append(k.get("home")) or 4242)
|
||||
monkeypatch.setattr(
|
||||
cli_main, "_cold_start_windows_gateway_after_update", lambda token=None: order.append("active") or True)
|
||||
token["cold_start_if_installed"] = True # both owe a spawn
|
||||
update_cmd._resume_windows_gateways_after_update(token)
|
||||
assert order == ["active", homes["beta"]]
|
||||
assert json.loads(beta_marker.read_text(encoding="utf-8"))["generation"] != beta_generation
|
||||
assert token["resume_needed"] is False
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_service_supervised_running_profile_is_not_cold_started(monkeypatch, tmp_path):
|
||||
"""A profile whose gateway is alive under an SCM service is skipped by the socket pause, so it is
|
||||
absent from ``token["profiles"]``; it must still count as RUNNING for the per-profile probe or its
|
||||
live attestation reads as dead and resume spawns a second, unsupervised gateway beside the
|
||||
restarted service."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
homes = _running_beta_pause_fixture(monkeypatch, tmp_path)
|
||||
svc_proc = SimpleNamespace(pid=900, profile="beta", path=homes["beta"])
|
||||
service = SimpleNamespace(name="HermesGw-beta", profile="beta", service_pid=800, gateway_pid=900,
|
||||
descendant_identities=(), service_create_time=1.0, gateway_create_time=2.0)
|
||||
monkeypatch.setattr(update_cmd_windows, "_discover_windows_gateways", lambda: ({900: svc_proc}, [service], {900}, [900]))
|
||||
monkeypatch.setattr(update_cmd_windows, "_request_socket_pauses", lambda *a: ({}, [], []))
|
||||
monkeypatch.setattr(update_cmd, "_stop_windows_gateway_service", lambda *a, **k: None)
|
||||
gateway_windows._write_start_attestation([900], "direct spawn (PID 900)", home=homes["beta"])
|
||||
|
||||
token = update_cmd._pause_windows_gateways_for_update()
|
||||
|
||||
assert token["services"] == ["HermesGw-beta"]
|
||||
assert token["profiles"] == {}
|
||||
assert "cold_start_profiles" not in token
|
||||
|
||||
Reference in New Issue
Block a user