diff --git a/gateway/restart.py b/gateway/restart.py index 240937032eb..41a74843295 100644 --- a/gateway/restart.py +++ b/gateway/restart.py @@ -45,6 +45,18 @@ def is_gateway_supervisor_process( } +def is_container_restart_context() -> bool: + """Return whether the gateway is running inside a container for restart + routing purposes (Docker/Podman ⇒ the detached setsid path dies with the + cgroup; exit-75 service restart is the only viable path). + + Extracted from the inline probe in the /restart handler so tests can mock + container detection hermetically — a real ``/.dockerenv`` on a + containerized CI runner otherwise flips the routing under the test. + """ + return os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv") + + def parse_restart_drain_timeout(raw: object) -> float: """Parse a configured drain timeout, falling back to the shared default.""" try: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 0068c9e0ee3..6cbd017d0c8 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -1616,10 +1616,13 @@ class GatewaySlashCommandsMixin: # Native supervisor markers cover direct systemd/launchd starts. The # explicit marker covers wrappers such as ``sudo env -i`` that strip # those markers before execing the foreground gateway. - from gateway.restart import is_gateway_supervisor_process + from gateway.restart import ( + is_container_restart_context, + is_gateway_supervisor_process, + ) _under_service = is_gateway_supervisor_process() - _in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv") + _in_container = is_container_restart_context() if _under_service or _in_container: self.request_restart(detached=False, via_service=True) else: diff --git a/tests/agent/test_copilot_acp_client.py b/tests/agent/test_copilot_acp_client.py index 2614b6309ad..a6b366c9c95 100644 --- a/tests/agent/test_copilot_acp_client.py +++ b/tests/agent/test_copilot_acp_client.py @@ -197,6 +197,16 @@ def test_run_prompt_preserves_real_home_when_profile_home_available(monkeypatch, monkeypatch.setenv("HOME", str(real_home)) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Hermeticity: an ambient HERMES_REAL_HOME (exported by Hermes' own + # terminal contract on dev boxes) outranks HOME in the candidate ladder, + # and an ambient TERMINAL_HOME_MODE would change the policy under test. + monkeypatch.delenv("HERMES_REAL_HOME", raising=False) + monkeypatch.delenv("TERMINAL_HOME_MODE", raising=False) + # Hermeticity: get_subprocess_home()'s auto mode prefers the profile home + # when is_container() is True — on a containerized CI runner that real + # probe flips the resolution this test asserts. The host/VM branch is the + # contract under test; pin containment off. + monkeypatch.setattr("hermes_constants.is_container", lambda: False) captured = {} client = _make_home_client(tmp_path) diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 1d287d984ae..f9bfcdf6561 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -19,6 +19,15 @@ async def test_restart_command_while_busy_requests_drain_without_interrupt(monke # Ensure INVOCATION_ID is NOT set — systemd sets this in service mode, # which changes the restart call signature. monkeypatch.delenv("INVOCATION_ID", raising=False) + monkeypatch.delenv("XPC_SERVICE_NAME", raising=False) + monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_EXTERNAL_SUPERVISOR", raising=False) + # Hermeticity: neutralize the real container probe (see + # test_restart_service_detection.py) — /.dockerenv on a containerized CI + # runner would otherwise route via_service=True under this test. + monkeypatch.setattr( + "gateway.restart.is_container_restart_context", lambda: False + ) runner, _adapter = make_restart_runner() runner.request_restart = MagicMock(return_value=True) event = MessageEvent( diff --git a/tests/gateway/test_restart_service_detection.py b/tests/gateway/test_restart_service_detection.py index 607d2e6ced2..65baef8b2ad 100644 --- a/tests/gateway/test_restart_service_detection.py +++ b/tests/gateway/test_restart_service_detection.py @@ -40,6 +40,13 @@ def _make_runner_with_mock_restart(tmp_path, monkeypatch): 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 diff --git a/tests/hermes_cli/test_gateway_wsl.py b/tests/hermes_cli/test_gateway_wsl.py index ae04c41e2ea..bedec3ad6d1 100644 --- a/tests/hermes_cli/test_gateway_wsl.py +++ b/tests/hermes_cli/test_gateway_wsl.py @@ -63,11 +63,13 @@ class TestSupportsSystemdServicesWSL: """WSL + working systemd → True.""" monkeypatch.setattr(gateway, "is_linux", lambda: True) monkeypatch.setattr(gateway, "is_termux", lambda: False) + monkeypatch.setattr( + gateway.shutil, "which", lambda _name: "/usr/bin/systemctl" + ) monkeypatch.setattr(gateway, "is_wsl", lambda: True) monkeypatch.setattr(gateway, "_wsl_systemd_operational", lambda: True) assert gateway.supports_systemd_services() is True - def test_termux_still_excluded(self, monkeypatch): """Termux → False regardless of WSL status.""" monkeypatch.setattr(gateway, "is_linux", lambda: True)