fix(process-registry): keep CLI workers off controlling tty

This commit is contained in:
bgrablin 2026-08-08 15:51:07 -05:00 committed by kshitij
parent bcdfdd51e5
commit ff5dfdecef
2 changed files with 94 additions and 24 deletions

View File

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

View File

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