diff --git a/tests/conftest.py b/tests/conftest.py index 18d27c8de3..bac6334869 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1001,6 +1001,7 @@ def _ensure_current_event_loop(request): # delivery is harmless. _LIVE_SYSTEM_GUARD_BYPASS_MARK = "live_system_guard_bypass" +_GATEWAY_LOOKALIKE_MARK = "spawns_gateway_lookalike" _REQUIRES_WAL_MARK = "requires_wal" @@ -1153,6 +1154,12 @@ def pytest_configure(config): # noqa: D401 — pytest hook "(only for tests that genuinely need real os.kill / subprocess " "behaviour — e.g. PTY tests that signal their own child).", ) + config.addinivalue_line( + "markers", + f"{_GATEWAY_LOOKALIKE_MARK}: the test spawns and reaps its own stub " + "child whose argv matches the gateway runtime matcher; only the " + "real-gateway spawn check is lifted, os.kill stays guarded.", + ) config.addinivalue_line( "markers", f"{_REQUIRES_WAL_MARK}: test needs the runtime to actually enable " @@ -1319,6 +1326,7 @@ def _live_system_guard(request, monkeypatch): import subprocess as _subprocess test_pid = _os.getpid() + lookalike_ok = request.node.get_closest_marker(_GATEWAY_LOOKALIKE_MARK) is not None # Capture the test process's existing children at fixture start — # any *new* children spawned by the test are also allowlisted via # the live psutil walk below. Static set keeps the fast path cheap. @@ -1545,6 +1553,28 @@ def _live_system_guard(request, monkeypatch): "needed (e.g. an integration test testing the update " "flow against a dedicated throwaway repo)." ) + # Block spawning a REAL gateway runtime (``python -m hermes_cli.main + # gateway run|start|restart``). ``_spawn_hermes_action`` launches it + # with start_new_session=True, so it outlives the pytest worker; the + # child inherits the pytest-tmp HERMES_HOME, resolves the DEVELOPER's + # ``hermes-gateway`` systemd unit (a tmp home hashes to no profile + # suffix), restarts the live gateway, and the survivors squat the + # webhook port. 2026-09-03: 39 such orphans lived 6 days after a + # sibling refactor moved the spawn seam and left tests patching the + # facade. The canonical matcher, never an argv substring. + from gateway.status import _gateway_command_subcommand + if not lookalike_ok and _gateway_command_subcommand(cmd_str) in ("run", "start", "restart"): + raise RuntimeError( + f"tests/conftest.py live-system guard: blocked " + f"subprocess.{name}({cmd!r}) — this would spawn a REAL " + "hermes gateway runtime that outlives the test (it is " + "detached), restarts the developer's live gateway, and " + "holds the webhook port. Patch the spawn seam where " + "production reads it (hermes_cli.web_server_gateway." + "_spawn_hermes_action), or mark with " + "@pytest.mark.spawns_gateway_lookalike a test that spawns " + "and reaps its own stub child." + ) def _wrap_subprocess(name, real): def _guarded(cmd, *args, **kwargs): diff --git a/tests/hermes_cli/test_cross_profile_kill_refusal.py b/tests/hermes_cli/test_cross_profile_kill_refusal.py index 7b05e98d65..64d35d3db8 100644 --- a/tests/hermes_cli/test_cross_profile_kill_refusal.py +++ b/tests/hermes_cli/test_cross_profile_kill_refusal.py @@ -95,6 +95,9 @@ class TestRecordedGatewayHomeConflicts: ) +# The lookalike's argv (" gateway run") is what the guard's real-gateway spawn check matches; +# the child is a sleep stub the test kills in ``finally``, never a runtime. +@pytest.mark.spawns_gateway_lookalike @pytest.mark.skipif(sys.platform == "win32", reason="POSIX flock harness") class TestCrossProfileStopRefusal: def test_stop_profile_gateway_refuses_other_profiles_pid( @@ -163,6 +166,7 @@ class TestCrossProfileStopRefusal: proc.wait(timeout=10) +@pytest.mark.spawns_gateway_lookalike @pytest.mark.skipif(sys.platform == "win32", reason="POSIX flock harness") class TestProfileDeleteStopRefusal: def test_stop_gateway_process_refuses_other_profiles_pid( diff --git a/tests/hermes_cli/test_stderr_timestamp.py b/tests/hermes_cli/test_stderr_timestamp.py index 0d5ecec7ed..ac541a03f5 100644 --- a/tests/hermes_cli/test_stderr_timestamp.py +++ b/tests/hermes_cli/test_stderr_timestamp.py @@ -80,6 +80,9 @@ def test_prepare_skips_interactive_xpc_zero_even_for_gateway_argv(): ) +# The child is ``python -c `` carrying a "gateway run" tail as inert data, which is +# exactly what the guard's real-gateway spawn check matches; it exits at once. +@pytest.mark.spawns_gateway_lookalike def test_main_injects_flag_into_stale_gateway_child(tmp_path, monkeypatch): """Stale plist inner argv must grow --external-supervisor in the grandchild.""" monkeypatch.setenv("XPC_SERVICE_NAME", "ai.hermes.gateway-butler") diff --git a/tests/hermes_cli/test_venv_holder_windows_live.py b/tests/hermes_cli/test_venv_holder_windows_live.py index d419717543..b3e7c209c0 100644 --- a/tests/hermes_cli/test_venv_holder_windows_live.py +++ b/tests/hermes_cli/test_venv_holder_windows_live.py @@ -30,9 +30,12 @@ from pathlib import Path import pytest -pytestmark = pytest.mark.skipif( - sys.platform != "win32", reason="live Windows venv-holder E2E" -) +pytestmark = [ + pytest.mark.skipif(sys.platform != "win32", reason="live Windows venv-holder E2E"), + # ``_spawn`` sleepers carry a "gateway run" argv tail as inert data (the guard's real-gateway + # spawn check matches it); every child is ``_kill``ed by the test. + pytest.mark.spawns_gateway_lookalike, +] PROJECT_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/test_live_system_guard_self_test.py b/tests/test_live_system_guard_self_test.py index 40e9e6b5e1..448389ea73 100644 --- a/tests/test_live_system_guard_self_test.py +++ b/tests/test_live_system_guard_self_test.py @@ -20,6 +20,7 @@ from __future__ import annotations import os import signal import subprocess +import sys import types import pytest @@ -292,6 +293,32 @@ def test_subprocess_killall_hermes_blocked(): +# ──────────────────── real gateway runtime spawn ───────────────── + + +def test_subprocess_popen_real_gateway_restart_blocked(): + """``python -m hermes_cli.main gateway restart`` is a detached child that + inherits the pytest-tmp HERMES_HOME, resolves the developer's real + ``hermes-gateway`` unit, and outlives the test (39 six-day orphans squatted + the webhook port, 2026-09-03). Blocked at the spawn primitive.""" + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.Popen( + [sys.executable, "-m", "hermes_cli.main", "gateway", "restart"], + start_new_session=True, + ) + + +def test_subprocess_run_gateway_status_passes_through(): + """Only lifecycle verbs are blocked: ``gateway status`` (and every other + read-only subcommand) must still spawn — via the canonical matcher, not an + argv substring.""" + result = subprocess.run( + [sys.executable, "-c", "import sys; print(sys.argv[1:])", "-m", "hermes_cli.main", "gateway", "status"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + # ──────────────────── bypass marker ─────────────────────────────