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:
Teknium
2026-09-09 06:18:18 -07:00
parent 19f2f19987
commit ca16cafee4
5 changed files with 70 additions and 3 deletions

View File

@@ -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):

View File

@@ -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(

View File

@@ -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")

View File

@@ -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]

View File

@@ -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 ─────────────────────────────