An agent-issued `taskkill /F /IM python.exe` (or `pkill -9 python3`, `killall python`, `Stop-Process -Name python`, `taskkill /FI "IMAGENAME eq python.exe"`, `pgrep python | xargs kill`) from inside the supervised gateway killed the gateway: every branch of _GATEWAY_LIFECYCLE_PATTERN was anchored on a hermes/gateway token, and the supervised gateway is literally a `python` process. Branch E is token-aware (not a line regex) so option values are never read as targets, `-f` cmdline patterns are judged as patterns (`pkill -f 'python my_script.py'` passes, `pkill -f 'python -m hermes_cli.main'` does not), and other image names (`taskkill /F /IM agent-browser.exe`) stay killable. Numeric-PID kills stay out of scope: the explicit PID / `proc_*` id is the ownership-scoped route the terminal rejection now names. The guard never ran on the Windows Scheduled-Task topology either: the launcher exports only the generalized HERMES_SUPERVISED_CHILD marker, which gateway/restart.py never read. is_supervised_gateway_launch() reads it and gates the self-kill guards; is_gateway_supervisor_process() deliberately keeps ignoring it because it also selects the exit-75 restart route, which the task has no restart policy to honour (#113670). Supersedes the narrow `/IM python.exe` regex from #113671 (kept for authorship); the Windows spellings from #94379 (`hermes.exe gateway restart`, `taskkill`/`Stop-Process` on hermes-gateway tokens) ride along. Fixes #113667
94 lines
4.0 KiB
Python
94 lines
4.0 KiB
Python
"""Tests for /restart service-manager detection (launchd vs interactive).
|
|
|
|
The /restart handler routes through ``request_restart(via_service=True)``
|
|
when a service manager supervises the gateway, so the process exits with
|
|
the service-restart code and the manager relaunches it. Under macOS
|
|
launchd the plist uses ``KeepAlive.SuccessfulExit=false`` — a clean exit 0
|
|
is treated as a deliberate stop and the gateway stays dead (#43475) — so
|
|
launchd must be detected here in the handler, not only at the exit-code
|
|
site (which never runs unless ``via_service=True`` is already set).
|
|
|
|
launchd sets ``XPC_SERVICE_NAME`` to the job label for processes it
|
|
spawns. Interactive macOS shells inherit ``XPC_SERVICE_NAME=0`` (a
|
|
truthy string), so the probe must treat ``"0"`` as not-under-launchd:
|
|
routing an unsupervised interactive gateway to the service path would
|
|
make it exit non-zero with nothing to revive it.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
import gateway.run as gateway_run
|
|
from gateway.platforms.event import MessageEvent, MessageType
|
|
from gateway.restart import EXTERNAL_GATEWAY_SUPERVISOR_ENV
|
|
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source
|
|
|
|
|
|
def _make_restart_event(update_id: int | None = 100) -> MessageEvent:
|
|
return MessageEvent(
|
|
text="/restart",
|
|
message_type=MessageType.TEXT,
|
|
source=make_restart_source(),
|
|
message_id="m1",
|
|
platform_update_id=update_id,
|
|
)
|
|
|
|
|
|
def _make_runner_with_mock_restart(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
|
monkeypatch.delenv("XPC_SERVICE_NAME", raising=False)
|
|
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
|
monkeypatch.delenv(EXTERNAL_GATEWAY_SUPERVISOR_ENV, raising=False)
|
|
# Hermeticity: neutralize the real container probe — on a containerized
|
|
# CI runner /.dockerenv exists and would route every case via_service=True
|
|
# regardless of the env markers under test (the detection under test is
|
|
# the SUPERVISOR markers, not the runner's own containment).
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_container_restart_context", lambda: False
|
|
)
|
|
runner, _adapter = make_restart_runner()
|
|
runner.request_restart = MagicMock(return_value=True)
|
|
return runner
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_with_external_supervisor_marker_uses_service_path(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Wrapped supervisors can retain restart ownership without native markers."""
|
|
runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)
|
|
monkeypatch.setenv(EXTERNAL_GATEWAY_SUPERVISOR_ENV, "1")
|
|
|
|
await runner._handle_restart_command(_make_restart_event())
|
|
|
|
runner.request_restart.assert_called_once_with(detached=False, via_service=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("value", ["", "0", "false", "off"])
|
|
async def test_false_external_supervisor_marker_keeps_detached_path(
|
|
value, tmp_path, monkeypatch
|
|
):
|
|
runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)
|
|
monkeypatch.setenv(EXTERNAL_GATEWAY_SUPERVISOR_ENV, value)
|
|
|
|
await runner._handle_restart_command(_make_restart_event())
|
|
|
|
runner.request_restart.assert_called_once_with(detached=True, via_service=False)
|
|
|
|
|
|
def test_supervised_child_marker_is_a_launch_not_a_restart_route():
|
|
"""The Windows Scheduled-Task launcher exports only ``HERMES_SUPERVISED_CHILD``: that must make
|
|
the gateway a supervised LAUNCH (self-kill guards active, #113667) without selecting the exit-75
|
|
restart route, which the task cannot honour (#113670)."""
|
|
from gateway.restart import is_gateway_supervisor_process, is_supervised_gateway_launch
|
|
from hermes_cli.gateway_windows import _GATEWAY_ENV
|
|
|
|
task_env = dict(_GATEWAY_ENV)
|
|
assert task_env["HERMES_SUPERVISED_CHILD"] == "1"
|
|
assert is_supervised_gateway_launch(task_env) is True
|
|
assert is_gateway_supervisor_process(task_env) is False
|
|
assert is_supervised_gateway_launch({}) is False
|
|
assert is_supervised_gateway_launch({"INVOCATION_ID": "abc"}) is True
|