diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 6009837cd2d74..b758933e244c0 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -1649,6 +1649,10 @@ class TestSystemdCgroupIsolation: ENTIRE gateway cgroup, taking down the messaging control plane. """ + @pytest.fixture(autouse=True) + def _mark_gateway_process(self, monkeypatch): + monkeypatch.setenv("_HERMES_GATEWAY", "1") + def _fake_popen_capture(self): """Return (fake_popen, captured) where captured["argv"] gets the argv passed to subprocess.Popen.""" @@ -1780,6 +1784,63 @@ class TestSystemdCgroupIsolation: assert argv == ["/bin/bash", "-lic", "set +m; echo hello"], argv assert captured["start_new_session"] is True + def test_inherited_systemd_marker_does_not_scope_interactive_cli( + self, registry, monkeypatch + ): + """A CLI inside a supervised terminal must keep workers off its tty.""" + fake_popen, captured = self._fake_popen_capture() + + monkeypatch.setenv("INVOCATION_ID", "herdr-service-inherited-marker") + monkeypatch.delenv("_HERMES_GATEWAY", raising=False) + monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash") + monkeypatch.setattr( + "tools.process_registry._systemd_run_user_scope_available", + lambda: True, + ) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run") + + with patch("subprocess.Popen", side_effect=fake_popen), \ + patch("threading.Thread", return_value=MagicMock()), \ + patch.object(registry, "_write_checkpoint"): + registry.spawn_local("echo hello", cwd="/tmp") + + assert captured["argv"] == [ + "/bin/bash", + "-lic", + "set +m; echo hello", + ] + assert captured["start_new_session"] is True + + def test_inherited_systemd_marker_does_not_scope_interactive_cli_pty( + self, registry, monkeypatch + ): + """The same gateway-identity gate applies to PTY-backed workers.""" + from ptyprocess import PtyProcess + + fake_pty = MagicMock() + fake_pty.pid = 4321 + + monkeypatch.setenv("INVOCATION_ID", "herdr-service-inherited-marker") + monkeypatch.delenv("_HERMES_GATEWAY", raising=False) + monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash") + monkeypatch.setattr( + "tools.process_registry._systemd_run_user_scope_available", + lambda: True, + ) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run") + + with patch.object(PtyProcess, "spawn", return_value=fake_pty) as pty_spawn, \ + patch("threading.Thread", return_value=MagicMock()), \ + patch.object(registry, "_write_checkpoint"): + session = registry.spawn_local("codex", cwd="/tmp", use_pty=True) + + assert pty_spawn.call_args.args[0] == [ + "/bin/bash", + "-lic", + "set +m; codex", + ] + assert session.systemd_unit == "" + def test_systemd_post_spawn_failure_never_kills_gateway_process_group( self, registry, monkeypatch ): diff --git a/tools/process_registry.py b/tools/process_registry.py index a9e142eedf11c..abbf6836b489d 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -245,6 +245,27 @@ def _systemd_run_user_scope_available() -> bool: return available +def _is_supervised_gateway_process() -> bool: + """Return whether this process is in a supervised Hermes gateway runtime. + + Supervisor markers such as systemd's ``INVOCATION_ID`` are inherited by + every descendant. An interactive CLI launched from a supervised terminal + manager therefore cannot use that marker alone: its login-shell workers + would stay in the CLI's session and could take ownership of the controlling + tty. ``gateway.run`` adds ``_HERMES_GATEWAY`` to the gateway process tree; + unrelated supervised terminal managers do not. + """ + if os.environ.get("_HERMES_GATEWAY") != "1": + return False + + try: + from gateway.restart import is_gateway_supervisor_process + + return is_gateway_supervisor_process() + except Exception: + return False + + def _build_systemd_scope_argv( shell_argv: List[str], unit_suffix: str, @@ -991,18 +1012,12 @@ class ProcessRegistry: # Cgroup isolation for PTY mode (#70716, reviewer gap #1): # Wrap the PTY command in a systemd scope so interactive # executors get their own cgroup, same as pipe mode. - pty_use_systemd_scope = False - try: - from gateway.restart import is_gateway_supervisor_process - - pty_under_supervisor = is_gateway_supervisor_process() - pty_use_systemd_scope = ( - not _IS_WINDOWS - and pty_under_supervisor - and _systemd_run_user_scope_available() - ) - except Exception: - pty_use_systemd_scope = False + pty_under_supervisor = _is_supervised_gateway_process() + pty_use_systemd_scope = ( + not _IS_WINDOWS + and pty_under_supervisor + and _systemd_run_user_scope_available() + ) if pty_use_systemd_scope: pty_argv = _build_systemd_scope_argv( @@ -1081,18 +1096,12 @@ class ProcessRegistry: # for both pipe mode and the PTY path above. shell_argv = [user_shell, "-lic", f"set +m; {safe_command}"] use_systemd_scope = False - under_supervisor = False - try: - from gateway.restart import is_gateway_supervisor_process - - under_supervisor = is_gateway_supervisor_process() - use_systemd_scope = ( - not _IS_WINDOWS - and under_supervisor - and _systemd_run_user_scope_available() - ) - except Exception: - use_systemd_scope = False + under_supervisor = _is_supervised_gateway_process() + use_systemd_scope = ( + not _IS_WINDOWS + and under_supervisor + and _systemd_run_user_scope_available() + ) if use_systemd_scope: unit_suffix = (