test(guard): block spawning a real gateway runtime from tests
Tests that exercise the dashboard's gateway-restart path can end up spawning a REAL `python -m hermes_cli.main gateway restart` child when the spawn seam is not intercepted. `_spawn_hermes_action` launches it with start_new_session=True, so it outlives the pytest worker; the child inherits the pytest-tmp HERMES_HOME, which is not a profile and hashes to no service suffix, so `get_service_name()` resolves the DEVELOPER's `hermes-gateway` unit, `systemd_restart` restarts the live gateway, and without systemd the fallback runs `run_gateway()` in-process forever and squats the webhook port. Live repro on this machine (origin/main): an unintercepted spawn of ["gateway", "restart"] from a test restarted the production gateway (MainPID 136820 -> 1689090, NRestarts=1). On 2026-09-03 a sibling refactor moved `_spawn_hermes_action` from the `hermes_cli.web_server` facade to `hermes_cli.web_server_gateway` ~10 minutes before the tests were repointed; runs in that window patched a name production never read and left 39 orphans alive for six days. The live-system guard now rejects any subprocess primitive whose command line the canonical matcher (`gateway.status. _gateway_command_subcommand`) classifies as `gateway run|start|restart`. Argv substrings are never consulted, so `gateway status`, `gateway --help`, `hermes_cli.main serve`, etc. pass through. Three files that deliberately spawn and reap a stub child with a gateway-shaped argv (flock holders, sleep sleepers with an argv tail) opt out with the new `spawns_gateway_lookalike` marker, which lifts only this check and keeps os.kill guarded. Two canary tests pin the block and the pass-through.
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -95,6 +95,9 @@ class TestRecordedGatewayHomeConflicts:
|
||||
)
|
||||
|
||||
|
||||
# The lookalike's argv ("<stub> 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(
|
||||
|
||||
@@ -80,6 +80,9 @@ def test_prepare_skips_interactive_xpc_zero_even_for_gateway_argv():
|
||||
)
|
||||
|
||||
|
||||
# The child is ``python -c <record argv>`` 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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user