2810 lines
109 KiB
Python
2810 lines
109 KiB
Python
"""Tests for tools/process_registry.py — ProcessRegistry query methods, pruning, checkpoint."""
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from tools.environments.local import _HERMES_PROVIDER_ENV_FORCE_PREFIX
|
|
from tools.process_registry import (
|
|
ProcessRegistry,
|
|
ProcessSession,
|
|
FINISHED_TTL_SECONDS,
|
|
MAX_PROCESSES,
|
|
MAX_ACTIVE_PROCESS_AGE,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def registry():
|
|
"""Create a fresh ProcessRegistry."""
|
|
return ProcessRegistry()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_systemd_scope_cache():
|
|
"""Reset the cached ``systemd-run --user --scope`` availability flag
|
|
before each test so a probe run on a real systemd host (where
|
|
``INVOCATION_ID`` is set) doesn't leak into tests that mock
|
|
``subprocess.Popen``. Tests that exercise the probe directly reset the
|
|
cache themselves."""
|
|
import tools.process_registry as _pr
|
|
|
|
original = _pr._SYSTEMD_SCOPE_AVAILABLE
|
|
_pr._SYSTEMD_SCOPE_AVAILABLE = False
|
|
yield
|
|
_pr._SYSTEMD_SCOPE_AVAILABLE = original
|
|
|
|
|
|
def _make_session(
|
|
sid="proc_test123",
|
|
command="echo hello",
|
|
task_id="t1",
|
|
exited=False,
|
|
exit_code=None,
|
|
output="",
|
|
started_at=None,
|
|
) -> ProcessSession:
|
|
"""Helper to create a ProcessSession for testing."""
|
|
s = ProcessSession(
|
|
id=sid,
|
|
command=command,
|
|
task_id=task_id,
|
|
started_at=started_at or time.time(),
|
|
exited=exited,
|
|
exit_code=exit_code,
|
|
output_buffer=output,
|
|
)
|
|
return s
|
|
|
|
|
|
def _spawn_python_sleep(seconds: float) -> subprocess.Popen:
|
|
"""Spawn a portable short-lived Python sleep process."""
|
|
return subprocess.Popen(
|
|
[sys.executable, "-c", f"import time; time.sleep({seconds})"],
|
|
)
|
|
|
|
|
|
def test_kill_started_since_preserves_preexisting_and_foreign_processes(registry):
|
|
old = _make_session(sid="proc_old", task_id="session-a")
|
|
finished = _make_session(
|
|
sid="proc_finished", task_id="session-a", exited=True, exit_code=0
|
|
)
|
|
registry._running[old.id] = old
|
|
registry._finished[finished.id] = finished
|
|
baseline = registry.snapshot_running_ids("session-a")
|
|
|
|
new = _make_session(sid="proc_new", task_id="session-a")
|
|
foreign = _make_session(sid="proc_foreign", task_id="session-b")
|
|
registry._running[new.id] = new
|
|
registry._running[foreign.id] = foreign
|
|
|
|
calls = []
|
|
|
|
def fake_kill(session_id, **kwargs):
|
|
calls.append((session_id, kwargs))
|
|
return {"status": "killed"}
|
|
|
|
registry.kill_process = fake_kill
|
|
|
|
assert baseline == frozenset({"proc_old"})
|
|
assert registry.kill_started_since(
|
|
"session-a", baseline, source="gateway_turn_timeout"
|
|
) == 1
|
|
assert calls == [
|
|
(
|
|
"proc_new",
|
|
{
|
|
"source": "gateway_turn_timeout",
|
|
"consume_output": True,
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
def test_kill_all_backward_compat_and_exclude_ids(registry):
|
|
"""kill_all keeps its historical default behavior (kill everything for
|
|
the task, consume_output=False, source='kill_all') and honors the new
|
|
exclude_ids kwarg that kill_started_since delegates through (#76188)."""
|
|
a = _make_session(sid="proc_a", task_id="session-a")
|
|
b = _make_session(sid="proc_b", task_id="session-a")
|
|
registry._running[a.id] = a
|
|
registry._running[b.id] = b
|
|
|
|
calls = []
|
|
|
|
def fake_kill(session_id, **kwargs):
|
|
calls.append((session_id, kwargs))
|
|
return {"status": "killed"}
|
|
|
|
registry.kill_process = fake_kill
|
|
|
|
assert registry.kill_all("session-a", exclude_ids=frozenset({"proc_a"})) == 1
|
|
assert calls == [
|
|
("proc_b", {"source": "kill_all", "consume_output": False})
|
|
]
|
|
|
|
calls.clear()
|
|
assert registry.kill_all("session-a") == 2
|
|
assert sorted(c[0] for c in calls) == ["proc_a", "proc_b"]
|
|
|
|
|
|
def _wait_until(predicate, timeout: float = 5.0, interval: float = 0.05) -> bool:
|
|
"""Poll a predicate until it returns truthy or the timeout elapses."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(interval)
|
|
return False
|
|
|
|
|
|
@pytest.mark.windows_only
|
|
def test_write_stdin_uses_str_for_windows_pty(registry):
|
|
"""pywinpty expects str input; bytes raises a PyString conversion error.
|
|
|
|
Windows-only: the str-vs-bytes choice IS the ``_IS_WINDOWS`` branch, and
|
|
the real pty handle it must satisfy (pywinpty) does not exist elsewhere.
|
|
"""
|
|
written = []
|
|
|
|
class _FakePty:
|
|
def write(self, value):
|
|
written.append(value)
|
|
|
|
session = _make_session(sid="pty-win")
|
|
session._pty = _FakePty()
|
|
registry._running[session.id] = session
|
|
|
|
result = registry.write_stdin(session.id, "hello\n")
|
|
|
|
assert result == {"status": "ok", "bytes_written": 6}
|
|
assert written == ["hello\n"]
|
|
assert isinstance(written[0], str)
|
|
|
|
|
|
@pytest.mark.linux_only
|
|
def test_write_stdin_uses_bytes_for_posix_pty(registry):
|
|
"""The POSIX counterpart: ptyprocess expects bytes, not str."""
|
|
written = []
|
|
|
|
class _FakePty:
|
|
def write(self, value):
|
|
written.append(value)
|
|
|
|
session = _make_session(sid="pty-posix")
|
|
session._pty = _FakePty()
|
|
registry._running[session.id] = session
|
|
|
|
result = registry.write_stdin(session.id, "hello\n")
|
|
|
|
assert result == {"status": "ok", "bytes_written": 6}
|
|
assert written == [b"hello\n"]
|
|
|
|
|
|
@pytest.mark.windows_only
|
|
def test_submit_stdin_uses_crlf_for_windows_pty(registry):
|
|
"""Enter on a Windows PTY is a carriage return, not a bare LF.
|
|
|
|
ConPTY cooked input only ends a line on ``\\r``; a bare ``\\n`` through
|
|
pywinpty is never delivered to a blocking line read (Python readline,
|
|
Go bufio.Scanner — the exact hang seen live with ``gh auth login``'s
|
|
"Press Enter to open the browser" prompt). submit_stdin must append
|
|
``\\r\\n`` for Windows PTY sessions.
|
|
"""
|
|
written = []
|
|
|
|
class _FakePty:
|
|
def write(self, value):
|
|
written.append(value)
|
|
|
|
session = _make_session(sid="pty-win-submit")
|
|
session._pty = _FakePty()
|
|
registry._running[session.id] = session
|
|
|
|
result = registry.submit_stdin(session.id, "Y")
|
|
|
|
assert result["status"] == "ok"
|
|
assert written == ["Y\r\n"]
|
|
|
|
|
|
@pytest.mark.windows_only
|
|
def test_submit_stdin_keeps_lf_for_windows_pipe(registry):
|
|
"""Non-PTY (Popen pipe) sessions keep the plain LF on Windows."""
|
|
session = _make_session(sid="pipe-win-submit")
|
|
fake_stdin = MagicMock()
|
|
session.process = MagicMock()
|
|
session.process.stdin = fake_stdin
|
|
registry._running[session.id] = session
|
|
|
|
result = registry.submit_stdin(session.id, "Y")
|
|
|
|
assert result["status"] == "ok"
|
|
fake_stdin.write.assert_called_once_with("Y\n")
|
|
|
|
|
|
# =========================================================================
|
|
# Get / Poll
|
|
# =========================================================================
|
|
|
|
class TestGetAndPoll:
|
|
def test_poll_running(self, registry):
|
|
s = _make_session(output="some output here")
|
|
registry._running[s.id] = s
|
|
result = registry.poll(s.id)
|
|
assert result["status"] == "running"
|
|
assert "some output" in result["output_preview"]
|
|
assert result["command"] == "echo hello"
|
|
|
|
def test_poll_exited(self, registry):
|
|
s = _make_session(exited=True, exit_code=0, output="done")
|
|
registry._finished[s.id] = s
|
|
result = registry.poll(s.id)
|
|
assert result["status"] == "exited"
|
|
assert result["exit_code"] == 0
|
|
|
|
|
|
def test_request_close_terminal_invokes_sink_without_killing(registry):
|
|
"""With a sink wired, close routes (session, process_id) to the UI and leaves
|
|
the process running — close is a view drop, not a kill."""
|
|
s = _make_session(sid="proc_close_live")
|
|
registry._running[s.id] = s
|
|
calls = []
|
|
registry.on_close = lambda session, pid: calls.append((session, pid))
|
|
|
|
result = registry.request_close_terminal(s.id)
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["closed"] == "proc_close_live"
|
|
assert calls == [(s, "proc_close_live")]
|
|
# Still tracked as running — closing the tab must not reap the process.
|
|
assert s.id in registry._running
|
|
|
|
|
|
def test_reader_loop_streams_incremental_chunks_from_read1(registry, monkeypatch):
|
|
"""Local reader must emit live chunks, not one EOF burst.
|
|
|
|
Regression for desktop agent terminals: ``stdout.read(4096)`` can buffer
|
|
until process exit for small periodic output. ``buffer.read1(4096)`` should
|
|
surface each chunk as it arrives.
|
|
"""
|
|
|
|
class _FakeBuffer:
|
|
def __init__(self, chunks):
|
|
self._chunks = list(chunks)
|
|
|
|
def read1(self, _n):
|
|
if self._chunks:
|
|
return self._chunks.pop(0)
|
|
return b""
|
|
|
|
class _FakeStdout:
|
|
def __init__(self, chunks):
|
|
self.buffer = _FakeBuffer(chunks)
|
|
|
|
class _FakeProcess:
|
|
def __init__(self, chunks):
|
|
self.stdout = _FakeStdout(chunks)
|
|
self.returncode = 0
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
session = _make_session(sid="proc_reader_live")
|
|
session.process = _FakeProcess([b"tick 1\n", b"tick 2\n", b"tick 3\n", b""])
|
|
emitted = []
|
|
moved = []
|
|
|
|
monkeypatch.setattr(registry, "_check_watch_patterns", lambda _s, _c: None)
|
|
monkeypatch.setattr(registry, "_emit_output", lambda _s, chunk: emitted.append(chunk))
|
|
monkeypatch.setattr(registry, "_move_to_finished", lambda _s: moved.append(_s.id))
|
|
|
|
registry._reader_loop(session)
|
|
|
|
assert emitted == ["tick 1\n", "tick 2\n", "tick 3\n"]
|
|
assert session.output_buffer == "tick 1\ntick 2\ntick 3\n"
|
|
assert session.exited is True
|
|
assert session.exit_code == 0
|
|
assert moved == ["proc_reader_live"]
|
|
|
|
|
|
# =========================================================================
|
|
# Incremental UTF-8 decoding across chunk boundaries
|
|
# (ported from openclaw/openclaw#112325)
|
|
# =========================================================================
|
|
|
|
|
|
class _FakeChunkBuffer:
|
|
def __init__(self, chunks):
|
|
self._chunks = list(chunks)
|
|
|
|
def read1(self, _n):
|
|
if self._chunks:
|
|
return self._chunks.pop(0)
|
|
return b""
|
|
|
|
|
|
class _FakeChunkStdout:
|
|
def __init__(self, chunks):
|
|
self.buffer = _FakeChunkBuffer(chunks)
|
|
|
|
|
|
class _FakeChunkProcess:
|
|
def __init__(self, chunks):
|
|
self.stdout = _FakeChunkStdout(chunks)
|
|
self.returncode = 0
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
|
|
def _run_reader(registry, monkeypatch, chunks, sid="proc_utf8"):
|
|
session = _make_session(sid=sid)
|
|
session.process = _FakeChunkProcess(chunks)
|
|
monkeypatch.setattr(registry, "_check_watch_patterns", lambda _s, _c: None)
|
|
monkeypatch.setattr(registry, "_emit_output", lambda _s, _c: None)
|
|
monkeypatch.setattr(registry, "_move_to_finished", lambda _s: None)
|
|
registry._reader_loop(session)
|
|
return session
|
|
|
|
|
|
def test_reader_loop_reassembles_multibyte_char_split_across_chunks(registry, monkeypatch):
|
|
"""A UTF-8 char split across two read1() chunks must not become U+FFFD.
|
|
|
|
Before the incremental decoder, each chunk was decoded statelessly with
|
|
``errors="replace"``, so ``é`` (0xC3 0xA9) straddling a 4096-byte read
|
|
boundary decoded as two replacement characters.
|
|
"""
|
|
session = _run_reader(registry, monkeypatch, [b"caf\xc3", b"\xa9 ok\n"])
|
|
assert session.output_buffer == "café ok\n"
|
|
assert "\ufffd" not in session.output_buffer
|
|
|
|
|
|
def test_reader_loop_reassembles_four_byte_char_split_three_ways(registry, monkeypatch):
|
|
"""A 4-byte emoji fragmented across three reads reassembles cleanly."""
|
|
session = _run_reader(registry, monkeypatch, [b"\xf0", b"\x9f\x92", b"\xa9\n"])
|
|
assert session.output_buffer == "\U0001f4a9\n"
|
|
|
|
|
|
def test_reader_loop_flushes_truncated_multibyte_tail_at_eof(registry, monkeypatch):
|
|
"""A sequence truncated by process exit flushes as a single U+FFFD."""
|
|
session = _run_reader(registry, monkeypatch, [b"ok \xe2\x82"])
|
|
assert session.output_buffer == "ok \ufffd"
|
|
|
|
|
|
def test_reader_loop_still_replaces_genuinely_invalid_bytes(registry, monkeypatch):
|
|
"""Truly invalid bytes keep the errors="replace" behavior."""
|
|
session = _run_reader(registry, monkeypatch, [b"ok\xffdone\n"])
|
|
assert session.output_buffer == "ok\ufffddone\n"
|
|
|
|
|
|
def test_pty_reader_loop_reassembles_multibyte_char_split_across_chunks(registry, monkeypatch):
|
|
"""The PTY reader gets the same incremental-decode treatment."""
|
|
|
|
class _FakePty:
|
|
def __init__(self, chunks):
|
|
self._chunks = list(chunks)
|
|
self.exitstatus = 0
|
|
|
|
def isalive(self):
|
|
return bool(self._chunks)
|
|
|
|
def read(self, _n):
|
|
if self._chunks:
|
|
return self._chunks.pop(0)
|
|
raise EOFError
|
|
|
|
def wait(self):
|
|
return 0
|
|
|
|
session = _make_session(sid="proc_pty_utf8")
|
|
session._pty = _FakePty([b"caf\xc3", b"\xa9\n"])
|
|
monkeypatch.setattr(registry, "_check_watch_patterns", lambda _s, _c: None)
|
|
monkeypatch.setattr(registry, "_emit_output", lambda _s, _c: None)
|
|
monkeypatch.setattr(registry, "_move_to_finished", lambda _s: None)
|
|
|
|
registry._pty_reader_loop(session)
|
|
|
|
assert session.output_buffer == "café\n"
|
|
assert "\ufffd" not in session.output_buffer
|
|
|
|
|
|
# =========================================================================
|
|
# Orphaned-pipe reconciliation (issue #17327)
|
|
# =========================================================================
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: uses setsid/fcntl")
|
|
class TestOrphanedPipeReconciliation:
|
|
"""Regression tests for issue #17327.
|
|
|
|
`hermes update` in Feishu spawned a background subprocess that restarted
|
|
the gateway; the direct child exited quickly but a descendant daemon
|
|
held the stdout pipe open. `_reader_loop.finally` never ran, so
|
|
`session.exited` stayed False and the agent polled 74 times over 7
|
|
minutes, all returning `status: running`.
|
|
|
|
The fix is `_reconcile_local_exit()`: poll() and wait() now check the
|
|
direct `Popen.poll()` before trusting `session.exited`.
|
|
"""
|
|
|
|
def test_reconcile_flips_exited_when_direct_child_done(self, registry):
|
|
"""Direct child exited but reader thread is blocked on orphaned pipe."""
|
|
# Simulate the orphaned-pipe scenario: direct child exited, but a
|
|
# descendant holds stdout open so the reader never sees EOF.
|
|
# Approach: spawn `sh -c 'sleep 10 &'` with setsid — sh forks the
|
|
# sleep into a new session group, exits immediately, but sleep
|
|
# inherits the stdout pipe and keeps it open.
|
|
proc = subprocess.Popen(
|
|
["sh", "-c", "exec 1>&2; ( sleep 30 ) & disown; exit 0"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
preexec_fn=os.setsid,
|
|
)
|
|
|
|
s = _make_session(sid="proc_orphan_test")
|
|
s.process = proc
|
|
s.pid = proc.pid
|
|
registry._running[s.id] = s
|
|
|
|
# Wait for the direct child to exit. We don't start a reader thread,
|
|
# so session.exited stays False (mimicking the stuck-reader state).
|
|
assert _wait_until(lambda: proc.poll() is not None, timeout=5.0), (
|
|
"Direct child should exit quickly (sh exits, sleep descendant "
|
|
"holds the pipe open)"
|
|
)
|
|
|
|
# Before the fix: poll would return "running" forever.
|
|
# After the fix: poll reconciles against proc.poll() and flips.
|
|
assert s.exited is False # Precondition: reader hasn't updated it.
|
|
result = registry.poll(s.id)
|
|
assert result["status"] == "exited", (
|
|
f"Expected reconciled 'exited' status; got {result!r}. "
|
|
"This is issue #17327 — reader is blocked on orphaned pipe."
|
|
)
|
|
assert result["exit_code"] == 0
|
|
assert s.exited is True
|
|
assert s.id in registry._finished
|
|
assert s.id not in registry._running
|
|
|
|
# Clean up the orphaned descendant.
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
def test_wait_returns_when_reader_blocked(self, registry):
|
|
"""wait() must also reconcile — not just poll()."""
|
|
proc = subprocess.Popen(
|
|
["sh", "-c", "( sleep 30 ) & disown; exit 0"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
preexec_fn=os.setsid,
|
|
)
|
|
|
|
s = _make_session(sid="proc_wait_orphan")
|
|
s.process = proc
|
|
s.pid = proc.pid
|
|
registry._running[s.id] = s
|
|
|
|
assert _wait_until(lambda: proc.poll() is not None, timeout=5.0)
|
|
|
|
start = time.monotonic()
|
|
result = registry.wait(s.id, timeout=10)
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert result["status"] == "exited", result
|
|
assert elapsed < 5.0, (
|
|
f"wait() should return ~immediately via reconcile; took {elapsed:.1f}s"
|
|
)
|
|
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
def test_wait_wakes_when_session_moves_to_finished(self, registry):
|
|
"""wait() should not sleep for the old 1s polling tick after exit."""
|
|
s = _make_session(sid="proc_wait_event", output="done")
|
|
registry._running[s.id] = s
|
|
|
|
def finish_later():
|
|
time.sleep(0.05)
|
|
s.exited = True
|
|
s.exit_code = 0
|
|
with patch.object(registry, "_write_checkpoint"):
|
|
registry._move_to_finished(s)
|
|
|
|
t = threading.Thread(target=finish_later)
|
|
t.start()
|
|
start = time.monotonic()
|
|
try:
|
|
result = registry.wait(s.id, timeout=5)
|
|
finally:
|
|
t.join(timeout=1)
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert result["status"] == "exited", result
|
|
assert result["exit_code"] == 0
|
|
assert elapsed < 0.9 # must stay under the old 1s poll tick being regression-tested, f"wait() should wake on completion; took {elapsed:.3f}s"
|
|
|
|
|
|
# =========================================================================
|
|
# Read log
|
|
# =========================================================================
|
|
|
|
class TestReadLog:
|
|
def test_read_full_log(self, registry):
|
|
lines = "\n".join([f"line {i}" for i in range(50)])
|
|
s = _make_session(output=lines)
|
|
registry._running[s.id] = s
|
|
result = registry.read_log(s.id)
|
|
assert result["total_lines"] == 50
|
|
|
|
def test_read_with_offset(self, registry):
|
|
lines = "\n".join([f"line {i}" for i in range(100)])
|
|
s = _make_session(output=lines)
|
|
registry._running[s.id] = s
|
|
result = registry.read_log(s.id, offset=10, limit=5)
|
|
assert "5 lines" in result["showing"]
|
|
|
|
|
|
# =========================================================================
|
|
# Stdin helpers
|
|
# =========================================================================
|
|
|
|
class TestStdinHelpers:
|
|
def test_close_stdin_pipe_mode(self, registry):
|
|
proc = MagicMock()
|
|
proc.stdin = MagicMock()
|
|
s = _make_session()
|
|
s.process = proc
|
|
registry._running[s.id] = s
|
|
|
|
result = registry.close_stdin(s.id)
|
|
|
|
proc.stdin.close.assert_called_once()
|
|
assert result["status"] == "ok"
|
|
|
|
def test_close_stdin_allows_eof_driven_process_to_finish(self, registry, tmp_path):
|
|
"""PTY mode: writing data + sending EOF lets an EOF-driven child finish.
|
|
|
|
Background non-PTY mode used to expose subprocess stdin via a pipe,
|
|
but PR #214b95392 detached non-PTY stdin to DEVNULL to fix keyboard
|
|
lockout (#17959). For interactive stdin → PTY mode is now the only
|
|
supported path.
|
|
"""
|
|
session = registry.spawn_local(
|
|
'python3 -c "import sys; print(sys.stdin.read().strip())"',
|
|
cwd=str(tmp_path),
|
|
use_pty=True,
|
|
)
|
|
|
|
try:
|
|
# Wait for the PTY child to be up rather than sleeping blindly.
|
|
assert _wait_until(
|
|
lambda: registry.poll(session.id)["status"] == "running",
|
|
timeout=5.0,
|
|
interval=0.02,
|
|
), "PTY session never reached running"
|
|
assert registry.submit_stdin(session.id, "hello")["status"] == "ok"
|
|
assert registry.close_stdin(session.id)["status"] == "ok"
|
|
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline:
|
|
poll = registry.poll(session.id)
|
|
if poll["status"] == "exited":
|
|
assert poll["exit_code"] == 0
|
|
assert "hello" in poll["output_preview"]
|
|
return
|
|
time.sleep(0.02)
|
|
|
|
pytest.fail("process did not exit after stdin was closed")
|
|
finally:
|
|
registry.kill_process(session.id)
|
|
|
|
|
|
# =========================================================================
|
|
# List sessions
|
|
# =========================================================================
|
|
|
|
class TestListSessions:
|
|
def test_filter_by_task_id(self, registry):
|
|
s1 = _make_session(sid="proc_1", task_id="t1")
|
|
s2 = _make_session(sid="proc_2", task_id="t2")
|
|
registry._running[s1.id] = s1
|
|
registry._running[s2.id] = s2
|
|
result = registry.list_sessions(task_id="t1")
|
|
assert len(result) == 1
|
|
assert result[0]["session_id"] == "proc_1"
|
|
|
|
def test_session_key_surfaces_cross_task_processes(self, registry):
|
|
"""A bg process under the same gateway session but a DIFFERENT task is
|
|
surfaced when session_key is passed, and flagged session_scoped (#29177).
|
|
"""
|
|
# Current turn's task = "t_now"; forgotten preview server = "t_old"
|
|
# but both share gateway session_key "gw1".
|
|
own = _make_session(sid="proc_own", task_id="t_now")
|
|
own.session_key = "gw1"
|
|
forgotten = _make_session(sid="proc_forgotten", task_id="t_old")
|
|
forgotten.session_key = "gw1"
|
|
other = _make_session(sid="proc_other", task_id="t_x")
|
|
other.session_key = "gw_other"
|
|
registry._running[own.id] = own
|
|
registry._running[forgotten.id] = forgotten
|
|
registry._running[other.id] = other
|
|
|
|
# Task-only (legacy) view sees just the current task's process.
|
|
legacy = registry.list_sessions(task_id="t_now")
|
|
assert {r["session_id"] for r in legacy} == {"proc_own"}
|
|
|
|
# With session_key, the forgotten process under the same gateway
|
|
# session is surfaced and flagged; the unrelated session is not.
|
|
result = registry.list_sessions(task_id="t_now", session_key="gw1")
|
|
by_id = {r["session_id"]: r for r in result}
|
|
assert set(by_id) == {"proc_own", "proc_forgotten"}
|
|
assert by_id["proc_forgotten"].get("session_scoped") is True
|
|
assert "session_scoped" not in by_id["proc_own"]
|
|
|
|
# =========================================================================
|
|
# Active process queries
|
|
# =========================================================================
|
|
|
|
class TestActiveQueries:
|
|
def test_has_active_processes(self, registry):
|
|
s = _make_session(task_id="t1")
|
|
registry._running[s.id] = s
|
|
assert registry.has_active_processes("t1") is True
|
|
assert registry.has_active_processes("t2") is False
|
|
|
|
def test_has_active_for_session_with_max_age_stale(self, registry):
|
|
"""Stale process (older than max_active_age) is ignored."""
|
|
s = _make_session(started_at=time.time() - 90000) # 25 hours ago
|
|
s.session_key = "gw_session_1"
|
|
registry._running[s.id] = s
|
|
assert registry.has_active_for_session("gw_session_1", max_active_age=86400) is False
|
|
|
|
# =========================================================================
|
|
# Pruning
|
|
# =========================================================================
|
|
|
|
class TestPruning:
|
|
def test_prune_expired_finished(self, registry):
|
|
old_session = _make_session(
|
|
sid="proc_old",
|
|
exited=True,
|
|
started_at=time.time() - FINISHED_TTL_SECONDS - 100,
|
|
)
|
|
registry._finished[old_session.id] = old_session
|
|
registry._prune_if_needed()
|
|
assert "proc_old" not in registry._finished
|
|
|
|
def test_prune_over_max_removes_oldest(self, registry):
|
|
# Fill up to MAX_PROCESSES
|
|
for i in range(MAX_PROCESSES):
|
|
s = _make_session(
|
|
sid=f"proc_{i}",
|
|
exited=True,
|
|
started_at=time.time() - i, # older as i increases
|
|
)
|
|
registry._finished[s.id] = s
|
|
|
|
# Add one more running to trigger prune
|
|
s = _make_session(sid="proc_new")
|
|
registry._running[s.id] = s
|
|
registry._prune_if_needed()
|
|
|
|
total = len(registry._running) + len(registry._finished)
|
|
assert total <= MAX_PROCESSES
|
|
|
|
|
|
# =========================================================================
|
|
# Spawn env sanitization
|
|
# =========================================================================
|
|
|
|
class TestSpawnEnvSanitization:
|
|
def test_spawn_local_strips_blocked_vars_from_background_env(self, registry):
|
|
captured = {}
|
|
|
|
def fake_popen(cmd, **kwargs):
|
|
captured["env"] = kwargs["env"]
|
|
proc = MagicMock()
|
|
proc.pid = 4321
|
|
proc.stdout = iter([])
|
|
proc.stdin = MagicMock()
|
|
proc.poll.return_value = None
|
|
return proc
|
|
|
|
fake_thread = MagicMock()
|
|
|
|
with patch.dict(os.environ, {
|
|
"PATH": "/usr/bin:/bin",
|
|
"HOME": "/home/user",
|
|
"USER": "tester",
|
|
"TELEGRAM_BOT_TOKEN": "bot-secret",
|
|
"FIRECRAWL_API_KEY": "fc-secret",
|
|
}, clear=True), \
|
|
patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
|
|
patch("subprocess.Popen", side_effect=fake_popen), \
|
|
patch("threading.Thread", return_value=fake_thread), \
|
|
patch.object(registry, "_write_checkpoint"):
|
|
registry.spawn_local(
|
|
"echo hello",
|
|
cwd="/tmp",
|
|
env_vars={
|
|
"MY_CUSTOM_VAR": "keep-me",
|
|
"TELEGRAM_BOT_TOKEN": "drop-me",
|
|
f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN": "forced-bot-token",
|
|
},
|
|
)
|
|
|
|
env = captured["env"]
|
|
assert env["MY_CUSTOM_VAR"] == "keep-me"
|
|
assert env["TELEGRAM_BOT_TOKEN"] == "forced-bot-token"
|
|
assert "FIRECRAWL_API_KEY" not in env
|
|
assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN" not in env
|
|
assert env["PYTHONUNBUFFERED"] == "1"
|
|
|
|
def test_spawn_via_env_checks_returncode_when_wrapper_fails(self, registry):
|
|
class FakeEnv:
|
|
def __init__(self):
|
|
self.commands = []
|
|
|
|
def execute(self, command, **kwargs):
|
|
self.commands.append((command, kwargs))
|
|
return {"output": "syntax error", "returncode": 2}
|
|
|
|
env = FakeEnv()
|
|
fake_thread = MagicMock()
|
|
|
|
with patch("tools.process_registry.threading.Thread", return_value=fake_thread), \
|
|
patch.object(registry, "_write_checkpoint"):
|
|
session = registry.spawn_via_env(env, "echo hello")
|
|
|
|
assert session.exited is True
|
|
assert session.exit_code == 2
|
|
assert session.pid is None
|
|
assert session.output_buffer == "syntax error"
|
|
fake_thread.start.assert_not_called()
|
|
# A failed launch must not be exposed as a running/tracked session.
|
|
assert session.id not in registry._running
|
|
|
|
def test_env_poller_quotes_temp_paths_with_spaces(self, registry):
|
|
session = _make_session(sid="proc_space")
|
|
session.exited = False
|
|
|
|
class FakeEnv:
|
|
def __init__(self):
|
|
self.commands = []
|
|
self._responses = iter([
|
|
{"output": "6 0\nhello\n"},
|
|
{"output": "1\n"},
|
|
{"output": "0\n"},
|
|
])
|
|
|
|
def execute(self, command, **kwargs):
|
|
self.commands.append((command, kwargs))
|
|
return next(self._responses)
|
|
|
|
env = FakeEnv()
|
|
|
|
with patch("tools.process_registry.time.sleep", return_value=None), \
|
|
patch.object(registry, "_move_to_finished"):
|
|
registry._env_poller_loop(
|
|
session,
|
|
env,
|
|
"/path with spaces/hermes_bg.log",
|
|
"/path with spaces/hermes_bg.pid",
|
|
"/path with spaces/hermes_bg.exit",
|
|
)
|
|
|
|
assert "'/path with spaces/hermes_bg.log'" in env.commands[0][0]
|
|
assert "cat '/path with spaces/hermes_bg.log'" not in env.commands[0][0]
|
|
assert env.commands[1][0] == "kill -0 \"$(cat '/path with spaces/hermes_bg.pid' 2>/dev/null)\" 2>/dev/null; echo $?"
|
|
assert env.commands[2][0] == "cat '/path with spaces/hermes_bg.exit' 2>/dev/null"
|
|
|
|
|
|
class TestEnvPollerIncrementalRead:
|
|
"""The sandbox log poller must read only new bytes, not the whole file.
|
|
|
|
Reading the whole file every poll made one poll cost grow with the total
|
|
output so far, so a long noisy job re-sent all of its output over the
|
|
docker or SSH channel every two seconds.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _run_poller(registry, session, responses):
|
|
"""Drive one poll cycle and hand back the commands the env saw."""
|
|
|
|
class FakeEnv:
|
|
def __init__(self):
|
|
self.commands = []
|
|
self._responses = iter(responses)
|
|
|
|
def execute(self, command, **kwargs):
|
|
self.commands.append(command)
|
|
return next(self._responses)
|
|
|
|
env = FakeEnv()
|
|
with patch("tools.process_registry.time.sleep", return_value=None), \
|
|
patch.object(registry, "_move_to_finished"):
|
|
registry._env_poller_loop(
|
|
session, env, "/tmp/bg.log", "/tmp/bg.pid", "/tmp/bg.exit"
|
|
)
|
|
return env.commands
|
|
|
|
def test_read_command_asks_only_for_new_bytes(self):
|
|
cmd = ProcessRegistry._log_delta_command("'/tmp/bg.log'", 4096)
|
|
# The offset is carried into the command, and the file is opened with
|
|
# tail rather than cat.
|
|
assert "O=4096" in cmd
|
|
assert "tail -c +$((O+1)) '/tmp/bg.log'" in cmd
|
|
assert "cat '/tmp/bg.log'" not in cmd
|
|
|
|
def test_read_command_starts_from_zero_on_first_poll(self):
|
|
cmd = ProcessRegistry._log_delta_command("'/tmp/bg.log'", 0)
|
|
assert "O=0" in cmd
|
|
|
|
@pytest.mark.skipif(not shutil.which("sh"), reason="needs a POSIX sh")
|
|
def test_read_command_holds_back_a_split_utf8_sequence(self, tmp_path):
|
|
"""A multibyte character straddling two polls must not be split.
|
|
|
|
The backend decodes each execute() result on its own, so returning
|
|
the first byte of an 'é' in one poll and the rest in the next would
|
|
yield replacement characters in the transcript (and break watch
|
|
patterns at the seam). Every prefix of a mixed ASCII/2/3/4-byte
|
|
string must come back decodable, with at most 3 bytes held back and
|
|
nothing held back once the trailing character is complete.
|
|
"""
|
|
full = "hé😀中a\n€bz🚀".encode()
|
|
log = tmp_path / "bg.log"
|
|
quoted = shlex.quote(str(log))
|
|
for n in range(1, len(full) + 1):
|
|
log.write_bytes(full[:n])
|
|
out = subprocess.run(
|
|
["sh", "-c", ProcessRegistry._log_delta_command(quoted, 0)],
|
|
capture_output=True, timeout=30,
|
|
).stdout
|
|
header, _, delta = out.partition(b"\n")
|
|
size, _offset = map(int, header.split())
|
|
delta.decode("utf-8") # must not raise
|
|
assert delta == full[:size]
|
|
complete = full[:n].decode("utf-8", "ignore").encode() == full[:n]
|
|
assert (n - size) == 0 if complete else 0 < (n - size) <= 3
|
|
|
|
def test_first_poll_reads_from_the_start(self, registry):
|
|
session = _make_session(sid="proc_delta")
|
|
session.exited = False
|
|
commands = self._run_poller(
|
|
registry,
|
|
session,
|
|
[
|
|
{"output": "11 0\nfirst chunk"},
|
|
{"output": "1\n"},
|
|
{"output": "0\n"},
|
|
],
|
|
)
|
|
assert "O=0" in commands[0]
|
|
assert session.output_buffer == "first chunk"
|
|
|
|
def test_delta_is_appended_not_replaced(self, registry):
|
|
session = _make_session(sid="proc_append", output="already here ")
|
|
session.exited = False
|
|
self._run_poller(
|
|
registry,
|
|
session,
|
|
[
|
|
{"output": "8 0\nand new"},
|
|
{"output": "1\n"},
|
|
{"output": "0\n"},
|
|
],
|
|
)
|
|
assert session.output_buffer == "already here and new"
|
|
|
|
def test_second_poll_asks_from_where_the_first_one_stopped(self, registry):
|
|
session = _make_session(sid="proc_two_polls")
|
|
session.exited = False
|
|
commands = self._run_poller(
|
|
registry,
|
|
session,
|
|
[
|
|
{"output": "11 0\nfirst chunk"},
|
|
{"output": "0\n"}, # still running, poll again
|
|
{"output": "17 11\n and more"},
|
|
{"output": "1\n"}, # gone now
|
|
{"output": "0\n"},
|
|
],
|
|
)
|
|
assert "O=0" in commands[0]
|
|
# The second read starts at byte 11, so the first chunk is not sent
|
|
# a second time.
|
|
assert "O=11" in commands[2]
|
|
assert session.output_buffer == "first chunk and more"
|
|
|
|
def test_truncated_log_drops_the_stale_buffer(self, registry):
|
|
session = _make_session(sid="proc_rotate")
|
|
session.exited = False
|
|
# The second read reports offset 0 even though the first one left off
|
|
# at byte 11. The file no longer reaches that byte, so it was rotated
|
|
# or truncated and the buffer we hold no longer matches it.
|
|
self._run_poller(
|
|
registry,
|
|
session,
|
|
[
|
|
{"output": "11 0\nfirst chunk"},
|
|
{"output": "0\n"}, # still running, poll again
|
|
{"output": "5 0\nfresh"},
|
|
{"output": "1\n"},
|
|
{"output": "0\n"},
|
|
],
|
|
)
|
|
assert session.output_buffer == "fresh"
|
|
|
|
def test_unreadable_header_leaves_the_buffer_alone(self, registry):
|
|
session = _make_session(sid="proc_bad", output="keep me")
|
|
session.exited = False
|
|
# No header at all, for example when the shell is missing one of the
|
|
# tools the command needs.
|
|
self._run_poller(
|
|
registry,
|
|
session,
|
|
[
|
|
{"output": ""},
|
|
{"output": "1\n"},
|
|
{"output": "0\n"},
|
|
],
|
|
)
|
|
assert session.output_buffer == "keep me"
|
|
|
|
def test_buffer_stays_within_the_cap(self, registry):
|
|
session = _make_session(sid="proc_cap")
|
|
session.exited = False
|
|
session.max_output_chars = 10
|
|
self._run_poller(
|
|
registry,
|
|
session,
|
|
[
|
|
{"output": "20 0\n" + "x" * 20},
|
|
{"output": "1\n"},
|
|
{"output": "0\n"},
|
|
],
|
|
)
|
|
assert session.output_buffer == "x" * 10
|
|
|
|
|
|
# =========================================================================
|
|
# Popen leak prevention
|
|
# =========================================================================
|
|
|
|
class TestPopenLeakOnSetupFailure:
|
|
"""Regression for issue #2749: subprocess orphaned when post-Popen setup raises."""
|
|
|
|
def test_popen_killed_when_thread_creation_fails(self, registry):
|
|
"""If Thread() raises after Popen, proc must be killed — not orphaned."""
|
|
killed = []
|
|
|
|
proc = MagicMock()
|
|
proc.pid = 9999
|
|
proc.stdout = iter([])
|
|
proc.stdin = MagicMock()
|
|
proc.poll.return_value = None
|
|
|
|
def fake_kill():
|
|
killed.append(True)
|
|
|
|
proc.kill = fake_kill
|
|
proc.wait = MagicMock()
|
|
|
|
def boom(*args, **kwargs):
|
|
raise RuntimeError("Thread creation failed")
|
|
|
|
# proc.pid is a MagicMock-backed fake; os.getpgid(fake_pid) would query
|
|
# the real OS for an arbitrary PID. On a busy host that PID may exist,
|
|
# in which case spawn_local's primary cleanup path
|
|
# (os.killpg(os.getpgid(pid), SIGKILL)) succeeds against an UNRELATED
|
|
# real process group and proc.kill() is never reached — flaky failure,
|
|
# and a real risk of SIGKILLing an innocent process group. Force the
|
|
# ProcessLookupError fallback so the test deterministically exercises
|
|
# proc.kill() and never issues a real killpg.
|
|
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
|
|
patch("subprocess.Popen", return_value=proc), \
|
|
patch("threading.Thread", side_effect=boom), \
|
|
patch("os.getpgid", side_effect=ProcessLookupError), \
|
|
patch.object(registry, "_write_checkpoint"):
|
|
with pytest.raises(RuntimeError, match="Thread creation failed"):
|
|
registry.spawn_local("echo hello", cwd="/tmp")
|
|
|
|
assert killed, "proc.kill() must be called when post-Popen setup raises"
|
|
|
|
# =========================================================================
|
|
# Spawn rewrite regression (issue #68915)
|
|
# =========================================================================
|
|
|
|
|
|
class TestSpawnRewriteCompoundBackground:
|
|
"""Verify that spawn_local rewrites `A && B &` patterns to avoid subshell deadlocks.
|
|
|
|
Issue #68915: when bash parses ``A && B &`` it forks a subshell ``(A && B) &``.
|
|
If B is a long-running server, the subshell never exits and holds the stdout
|
|
pipe open, causing a permanent deadlock. The rewriter wraps the tail to
|
|
``A && { B & }`` so no subshell fork occurs.
|
|
"""
|
|
|
|
def test_compound_and_background_gets_rewritten(self, registry):
|
|
"""A && B & must be rewritten to A && { B & } before Popen."""
|
|
captured_cmd = []
|
|
|
|
def fake_popen(args, **kwargs):
|
|
captured_cmd.append(args)
|
|
proc = MagicMock()
|
|
proc.pid = 1111
|
|
proc.stdout = MagicMock()
|
|
return proc
|
|
|
|
fake_thread = MagicMock()
|
|
fake_thread.daemon = False
|
|
|
|
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
|
|
patch("subprocess.Popen", side_effect=fake_popen), \
|
|
patch("threading.Thread", return_value=fake_thread), \
|
|
patch.object(registry, "_write_checkpoint"):
|
|
registry.spawn_local("cd /app && node server.js &>/tmp/srv.log &", cwd="/tmp")
|
|
|
|
assert len(captured_cmd) == 1
|
|
shell_cmd = captured_cmd[0]
|
|
# The command passed to Popen should be the REWRITTEN version
|
|
assert "&& { node server.js &>/tmp/srv.log & }" in shell_cmd[2]
|
|
|
|
def test_simple_background_preserved(self, registry):
|
|
"""Simple cmd & (no &&) must NOT be rewritten — no subshell bug."""
|
|
captured_cmd = []
|
|
|
|
def fake_popen(args, **kwargs):
|
|
captured_cmd.append(args)
|
|
proc = MagicMock()
|
|
proc.pid = 2222
|
|
proc.stdout = MagicMock()
|
|
return proc
|
|
|
|
fake_thread = MagicMock()
|
|
fake_thread.daemon = False
|
|
|
|
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
|
|
patch("subprocess.Popen", side_effect=fake_popen), \
|
|
patch("threading.Thread", return_value=fake_thread), \
|
|
patch.object(registry, "_write_checkpoint"):
|
|
registry.spawn_local("sleep 5 &", cwd="/tmp")
|
|
|
|
assert len(captured_cmd) == 1
|
|
shell_cmd = captured_cmd[0][2]
|
|
# Simple background must remain as-is
|
|
assert "sleep 5 &" in shell_cmd
|
|
|
|
def test_pty_path_uses_rewritten_command(self, registry):
|
|
"""PTY spawn path must also use the rewritten command (issue #68915)."""
|
|
mock_pty_proc = MagicMock()
|
|
mock_pty_proc.pid = 5555
|
|
|
|
mock_pty_module = MagicMock()
|
|
mock_pty_module.PtyProcess.spawn = MagicMock(return_value=mock_pty_proc)
|
|
|
|
fake_thread = MagicMock()
|
|
fake_thread.daemon = False
|
|
|
|
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
|
|
patch.dict("sys.modules", {"ptyprocess": mock_pty_module}), \
|
|
patch("threading.Thread", return_value=fake_thread), \
|
|
patch.object(registry, "_write_checkpoint"):
|
|
session = registry.spawn_local(
|
|
"cd /app && node server.js &",
|
|
cwd="/tmp",
|
|
use_pty=True,
|
|
)
|
|
|
|
assert mock_pty_module.PtyProcess.spawn.called, \
|
|
"PTY spawn should have been attempted"
|
|
pty_args = mock_pty_module.PtyProcess.spawn.call_args[0][0]
|
|
assert "&& { node server.js & }" in pty_args[2], \
|
|
f"PTY path should use rewritten command, got: {pty_args[2]}"
|
|
assert session.command == "cd /app && node server.js &"
|
|
|
|
|
|
# =========================================================================
|
|
# Checkpoint
|
|
# =========================================================================
|
|
|
|
class TestCheckpoint:
|
|
def test_recover_dead_pid(self, registry, tmp_path):
|
|
checkpoint = tmp_path / "procs.json"
|
|
checkpoint.write_text(json.dumps([{
|
|
"session_id": "proc_dead",
|
|
"command": "sleep 999",
|
|
"pid": 999999999, # almost certainly not running
|
|
"task_id": "t1",
|
|
}]))
|
|
with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint):
|
|
recovered = registry.recover_from_checkpoint()
|
|
assert recovered == 0
|
|
|
|
def test_recover_dead_wrapper_retries_unreaped_systemd_scope(
|
|
self, registry, tmp_path, monkeypatch
|
|
):
|
|
checkpoint = tmp_path / "procs.json"
|
|
entry = {
|
|
"session_id": "proc_dead_scope",
|
|
"command": "daemonize",
|
|
"pid": 999999999,
|
|
"pid_scope": "host",
|
|
"host_start_time": 123.0,
|
|
"systemd_unit": "hermes-worker-proc_dead_scope.scope",
|
|
}
|
|
checkpoint.write_text(json.dumps([entry]))
|
|
monkeypatch.setattr(registry, "_host_pid_is_ours", lambda *_args: False)
|
|
monkeypatch.setattr(registry, "_is_host_pid_alive", lambda *_args: False)
|
|
|
|
with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint), patch(
|
|
"tools.process_registry._stop_systemd_unit", return_value=False
|
|
) as stop_unit:
|
|
assert registry.recover_from_checkpoint() == 0
|
|
|
|
stop_unit.assert_called_once_with(entry["systemd_unit"])
|
|
assert json.loads(checkpoint.read_text()) == [entry]
|
|
|
|
def test_recover_dead_wrapper_drops_reaped_systemd_scope(
|
|
self, registry, tmp_path, monkeypatch
|
|
):
|
|
checkpoint = tmp_path / "procs.json"
|
|
entry = {
|
|
"session_id": "proc_dead_scope",
|
|
"command": "daemonize",
|
|
"pid": 999999999,
|
|
"pid_scope": "host",
|
|
"host_start_time": 123.0,
|
|
"systemd_unit": "hermes-worker-proc_dead_scope.scope",
|
|
}
|
|
checkpoint.write_text(json.dumps([entry]))
|
|
monkeypatch.setattr(registry, "_host_pid_is_ours", lambda *_args: False)
|
|
monkeypatch.setattr(registry, "_is_host_pid_alive", lambda *_args: False)
|
|
|
|
with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint), patch(
|
|
"tools.process_registry._stop_systemd_unit", return_value=True
|
|
) as stop_unit:
|
|
assert registry.recover_from_checkpoint() == 0
|
|
|
|
stop_unit.assert_called_once_with(entry["systemd_unit"])
|
|
assert json.loads(checkpoint.read_text()) == []
|
|
|
|
|
|
def test_recovery_skips_explicit_sandbox_backed_entries(self, registry, tmp_path):
|
|
checkpoint = tmp_path / "procs.json"
|
|
original = [{
|
|
"session_id": "proc_remote",
|
|
"command": "sleep 999",
|
|
"pid": os.getpid(),
|
|
"task_id": "t1",
|
|
"pid_scope": "sandbox",
|
|
}]
|
|
checkpoint.write_text(json.dumps(original))
|
|
|
|
with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint):
|
|
recovered = registry.recover_from_checkpoint()
|
|
assert recovered == 0
|
|
assert registry.get("proc_remote") is None
|
|
|
|
data = json.loads(checkpoint.read_text())
|
|
assert data == []
|
|
|
|
def test_checkpoint_redacts_command_with_inline_secret(self, registry, tmp_path):
|
|
"""Issue #77484: the checkpoint file persists raw commands; inline
|
|
credentials (e.g. ``curl -H 'Authorization: Bearer sk-...'``) must be
|
|
redacted before write. Recovery only uses command for display/logging
|
|
(the process is already running), so masking is lossless."""
|
|
checkpoint = tmp_path / "procs.json"
|
|
with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint):
|
|
secret = "sk-secret1234567890"
|
|
command = f"curl -H 'Authorization: Bearer {secret}' http://x"
|
|
s = _make_session(sid="proc_secret", command=command)
|
|
s.pid = 12345
|
|
s.host_start_time = int(time.time())
|
|
registry._running[s.id] = s
|
|
registry._write_checkpoint()
|
|
|
|
data = json.loads(checkpoint.read_text())
|
|
assert data[0]["session_id"] == "proc_secret"
|
|
assert secret not in data[0]["command"]
|
|
assert data[0]["command"] != command
|
|
|
|
# =========================================================================
|
|
# Kill process
|
|
# =========================================================================
|
|
|
|
class TestKillProcess:
|
|
def test_kill_already_exited(self, registry):
|
|
s = _make_session(exited=True, exit_code=0)
|
|
registry._finished[s.id] = s
|
|
result = registry.kill_process(s.id)
|
|
assert result["status"] == "already_exited"
|
|
|
|
|
|
def test_kill_detached_session_uses_host_pid(self, registry):
|
|
s = _make_session(sid="proc_detached", command="sleep 999")
|
|
s.pid = 424242
|
|
s.detached = True
|
|
registry._running[s.id] = s
|
|
|
|
terminate_calls = []
|
|
|
|
class FakeProcess:
|
|
def __init__(self, pid):
|
|
self.pid = pid
|
|
def children(self, recursive=False):
|
|
return []
|
|
def terminate(self):
|
|
terminate_calls.append(("terminate", self.pid))
|
|
|
|
import psutil as _psutil
|
|
|
|
try:
|
|
# Post-#21561: liveness probe routes through
|
|
# ``ProcessRegistry._is_host_pid_alive`` (→
|
|
# ``gateway.status._pid_exists``), and the actual kill on POSIX
|
|
# routes through ``psutil.Process(pid).terminate()``. Neither
|
|
# touches ``os.kill`` directly. Mock both seams. Disable the
|
|
# SIGKILL-escalation step (grace=0) so it doesn't call
|
|
# ``psutil.wait_procs`` on the FakeProcess.
|
|
with patch("gateway.status._pid_exists", return_value=True), \
|
|
patch.object(ProcessRegistry, "_daemon_term_grace_seconds",
|
|
staticmethod(lambda: 0.0)), \
|
|
patch.object(_psutil, "Process", side_effect=lambda pid: FakeProcess(pid)):
|
|
result = registry.kill_process(s.id)
|
|
|
|
assert result["status"] == "killed"
|
|
assert ("terminate", 424242) in terminate_calls
|
|
finally:
|
|
registry._running.pop(s.id, None)
|
|
|
|
|
|
# =========================================================================
|
|
# Tool handler
|
|
# =========================================================================
|
|
|
|
class TestProcessToolHandler:
|
|
def test_unknown_action(self):
|
|
from tools.process_registry import _handle_process
|
|
result = json.loads(_handle_process({"action": "unknown_action"}))
|
|
assert "error" in result
|
|
|
|
|
|
# =========================================================================
|
|
# format_process_notification + drain_notifications (shared helpers)
|
|
# =========================================================================
|
|
|
|
from tools.process_registry import format_process_notification
|
|
|
|
|
|
def test_drain_notifications_completion_callback_exception_fails_closed(registry):
|
|
event = {
|
|
"type": "completion",
|
|
"session_id": "proc_callback_error",
|
|
"session_key": "session-a",
|
|
"command": "safe-test-command",
|
|
"exit_code": 0,
|
|
"output": "done",
|
|
}
|
|
registry.completion_queue.put(event)
|
|
|
|
def broken(_event):
|
|
raise RuntimeError("ownership check exploded")
|
|
|
|
results = registry.drain_notifications(
|
|
session_key="session-a",
|
|
owns_event=broken,
|
|
)
|
|
|
|
assert results == []
|
|
assert registry.completion_queue.get_nowait() == event
|
|
assert registry.completion_queue.empty()
|
|
|
|
|
|
def test_drain_notifications_filters_async_delegation_by_session_key():
|
|
"""Async-delegation events should only be consumed by the matching session's drain.
|
|
|
|
Regression test for issue #58684: background delegation results delivered
|
|
to the wrong session when the user switches sessions while a subagent runs.
|
|
"""
|
|
from tools.process_registry import process_registry
|
|
|
|
# Clear the queue first
|
|
while not process_registry.completion_queue.empty():
|
|
process_registry.completion_queue.get_nowait()
|
|
|
|
try:
|
|
# Put events for different sessions
|
|
process_registry.completion_queue.put({
|
|
"type": "async_delegation",
|
|
"delegation_id": "deleg_session_a",
|
|
"session_key": "telegram:dm:111:user_a",
|
|
"goal": "task A",
|
|
"status": "completed",
|
|
"summary": "done A",
|
|
"api_calls": 1,
|
|
"duration_seconds": 0.5,
|
|
})
|
|
process_registry.completion_queue.put({
|
|
"type": "async_delegation",
|
|
"delegation_id": "deleg_session_b",
|
|
"session_key": "telegram:dm:222:user_b",
|
|
"goal": "task B",
|
|
"status": "completed",
|
|
"summary": "done B",
|
|
"api_calls": 1,
|
|
"duration_seconds": 0.3,
|
|
})
|
|
|
|
# Drain for session A — should only get deleg_session_a
|
|
results_a = process_registry.drain_notifications(session_key="telegram:dm:111:user_a")
|
|
assert len(results_a) == 1, (
|
|
f"Expected 1 event for session A, got {len(results_a)}"
|
|
)
|
|
assert results_a[0][0]["delegation_id"] == "deleg_session_a"
|
|
assert "done A" in results_a[0][1]
|
|
|
|
# Session B's event should have been re-queued — drain for session B
|
|
results_b = process_registry.drain_notifications(session_key="telegram:dm:222:user_b")
|
|
assert len(results_b) == 1, (
|
|
f"Expected 1 event for session B, got {len(results_b)}"
|
|
)
|
|
assert results_b[0][0]["delegation_id"] == "deleg_session_b"
|
|
assert "done B" in results_b[0][1]
|
|
|
|
# No more events should remain
|
|
assert process_registry.completion_queue.empty()
|
|
finally:
|
|
while not process_registry.completion_queue.empty():
|
|
process_registry.completion_queue.get_nowait()
|
|
|
|
|
|
def test_drain_notifications_owns_event_callback_beats_key_equality():
|
|
"""The positive-proof ownership callback consumes ONLY approved events —
|
|
including across a compression rotation where bare key equality would
|
|
wrongly re-queue the session's own pre-compression dispatch (#55578)."""
|
|
from tools.process_registry import process_registry
|
|
|
|
while not process_registry.completion_queue.empty():
|
|
process_registry.completion_queue.get_nowait()
|
|
|
|
try:
|
|
# Pre-compression dispatch: event carries the OLD key.
|
|
process_registry.completion_queue.put({
|
|
"type": "async_delegation",
|
|
"delegation_id": "deleg_precompress",
|
|
"session_key": "old_parent_key",
|
|
"goal": "task", "status": "completed", "summary": "mine",
|
|
"api_calls": 1, "duration_seconds": 0.1,
|
|
})
|
|
# Foreign event that plain key equality would also reject.
|
|
process_registry.completion_queue.put({
|
|
"type": "async_delegation",
|
|
"delegation_id": "deleg_foreign",
|
|
"session_key": "someone_else",
|
|
"goal": "task", "status": "completed", "summary": "not mine",
|
|
"api_calls": 1, "duration_seconds": 0.1,
|
|
})
|
|
|
|
# Chain-aware ownership: this session's lineage includes old_parent_key.
|
|
lineage = {"old_parent_key", "new_child_key"}
|
|
results = process_registry.drain_notifications(
|
|
session_key="new_child_key",
|
|
owns_event=lambda e: e.get("session_key") in lineage,
|
|
)
|
|
assert [r[0]["delegation_id"] for r in results] == ["deleg_precompress"]
|
|
|
|
# The foreign event was re-queued, not consumed.
|
|
leftover = process_registry.completion_queue.get_nowait()
|
|
assert leftover["delegation_id"] == "deleg_foreign"
|
|
finally:
|
|
while not process_registry.completion_queue.empty():
|
|
process_registry.completion_queue.get_nowait()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _terminate_host_pid — cross-platform process-tree termination
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTerminateHostPidWindows:
|
|
"""Windows branch uses ``taskkill /T /F`` — the documented MS tree-kill
|
|
primitive. We can't use psutil's ``children(recursive=True)`` /
|
|
``.terminate()`` path on Windows because (1) Windows doesn't maintain
|
|
a Unix-style process tree so the walk is unreliable, and (2)
|
|
``Process.terminate()`` on Windows is ``TerminateProcess()`` for the
|
|
target handle only, not the tree.
|
|
"""
|
|
|
|
@pytest.mark.windows_only
|
|
def test_windows_invokes_taskkill_with_tree_and_force_flags(self, monkeypatch):
|
|
"""The Windows branch must shell out to ``taskkill /PID N /T /F``.
|
|
|
|
Windows-only: ``taskkill.exe`` is the thing under test and only exists
|
|
here — with a faked ``_IS_WINDOWS`` the argv was asserted against a
|
|
binary that could never have run.
|
|
"""
|
|
from tools import process_registry as pr
|
|
|
|
captured = {}
|
|
|
|
def fake_run(args, **kwargs):
|
|
captured["args"] = args
|
|
captured["kwargs"] = kwargs
|
|
return MagicMock(returncode=0, stderr="", stdout="")
|
|
|
|
monkeypatch.setattr(pr.subprocess, "run", fake_run)
|
|
|
|
pr.ProcessRegistry._terminate_host_pid(12345)
|
|
|
|
assert captured["args"][0] == "taskkill"
|
|
assert "/PID" in captured["args"]
|
|
assert "12345" in captured["args"]
|
|
assert "/T" in captured["args"], "Tree flag required to reach descendants"
|
|
assert "/F" in captured["args"], "Force flag required for headless Chromium"
|
|
|
|
class TestTerminateHostPidPosix:
|
|
"""POSIX branch walks the tree via psutil and SIGTERMs children first."""
|
|
|
|
def test_posix_walks_tree_and_terminates_children_then_parent(self, monkeypatch):
|
|
from tools import process_registry as pr
|
|
import psutil
|
|
|
|
terminate_order = []
|
|
|
|
class _FakeChild:
|
|
def __init__(self, pid):
|
|
self.pid = pid
|
|
|
|
def terminate(self):
|
|
terminate_order.append(self.pid)
|
|
|
|
class _FakeParent:
|
|
def __init__(self, pid):
|
|
self.pid = pid
|
|
|
|
def children(self, recursive=False):
|
|
assert recursive is True
|
|
return [_FakeChild(101), _FakeChild(102), _FakeChild(103)]
|
|
|
|
def terminate(self):
|
|
terminate_order.append(self.pid)
|
|
|
|
monkeypatch.setattr(psutil, "Process", _FakeParent)
|
|
# This test covers only the SIGTERM tree-walk ordering; disable the
|
|
# SIGKILL-escalation step (which would call psutil.wait_procs on the
|
|
# fakes) by setting the grace to 0.
|
|
monkeypatch.setattr(pr.ProcessRegistry, "_daemon_term_grace_seconds",
|
|
staticmethod(lambda: 0.0))
|
|
|
|
pr.ProcessRegistry._terminate_host_pid(12345)
|
|
|
|
assert terminate_order == [101, 102, 103, 12345], (
|
|
"Children must be terminated before the parent"
|
|
)
|
|
|
|
def test_posix_oserror_falls_back_to_os_kill(self, monkeypatch):
|
|
from tools import process_registry as pr
|
|
import psutil
|
|
|
|
def boom(pid):
|
|
raise PermissionError("can't read /proc")
|
|
|
|
kill_calls = []
|
|
|
|
def fake_kill(pid, sig):
|
|
kill_calls.append((pid, sig))
|
|
|
|
monkeypatch.setattr(psutil, "Process", boom)
|
|
monkeypatch.setattr(pr.os, "kill", fake_kill)
|
|
|
|
pr.ProcessRegistry._terminate_host_pid(12345)
|
|
|
|
assert kill_calls == [(12345, signal.SIGTERM)]
|
|
|
|
|
|
# =========================================================================
|
|
# PID-reuse guard — a recycled PID/PGID must never be signalled.
|
|
#
|
|
# Regression: once a background-session process exits and is reaped, the kernel
|
|
# can recycle its PID onto an unrelated process (observed in the wild landing on
|
|
# a desktop browser's session leader, whose whole tree we then SIGTERMed —
|
|
# Firefox dying at irregular intervals). Identity is re-validated via the
|
|
# kernel start time captured at spawn before any signal is sent.
|
|
# =========================================================================
|
|
|
|
class TestPidReuseGuard:
|
|
def test_terminate_refuses_when_start_time_mismatches(self, registry):
|
|
"""A live PID whose start time changed (recycled) is NOT killed."""
|
|
proc = _spawn_python_sleep(30)
|
|
try:
|
|
real_start = ProcessRegistry._safe_host_start_time(proc.pid)
|
|
assert real_start is not None, "no /proc start time on this platform?"
|
|
# Simulate recycling: the recorded baseline no longer matches.
|
|
registry._terminate_host_pid(proc.pid, expected_start=real_start + 1)
|
|
# The process must still be alive — the guard refused to signal it.
|
|
assert not _wait_until(lambda: proc.poll() is not None, timeout=0.3)
|
|
assert proc.poll() is None
|
|
finally:
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
|
|
def test_refresh_detached_marks_recycled_pid_exited(self, registry):
|
|
"""A detached session whose PID got recycled is moved to finished."""
|
|
wrong_start = (ProcessRegistry._safe_host_start_time(os.getpid()) or 0) + 999
|
|
s = _make_session(sid="proc_detached")
|
|
s.pid = os.getpid() # alive, but...
|
|
s.pid_scope = "host"
|
|
s.detached = True
|
|
s.host_start_time = wrong_start # ...identity no longer matches
|
|
registry._running[s.id] = s
|
|
refreshed = registry._refresh_detached_session(s)
|
|
assert refreshed.exited is True
|
|
assert s.id in registry._finished
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32",
|
|
reason="POSIX SIGTERM→SIGKILL escalation; Windows uses taskkill /F")
|
|
class TestSigkillEscalation:
|
|
"""Bounded SIGTERM→SIGKILL escalation in _terminate_host_pid.
|
|
|
|
A daemon that ignores/stalls on SIGTERM must be force-killed after the
|
|
configured grace window so it can't leak indefinitely — while well-behaved
|
|
processes still exit cleanly on SIGTERM and the recycled-PID guard is never
|
|
bypassed.
|
|
"""
|
|
|
|
# A process that traps SIGTERM (ignores it): only SIGKILL stops it.
|
|
# It prints "ready" AFTER installing the handler so the parent never
|
|
# signals it during the startup window (before SIG_IGN is in place).
|
|
_TRAP = (
|
|
"import signal, sys, time;"
|
|
"signal.signal(signal.SIGTERM, signal.SIG_IGN);"
|
|
"sys.stdout.write('ready\\n'); sys.stdout.flush();"
|
|
"[time.sleep(0.2) for _ in iter(int, 1)]"
|
|
)
|
|
|
|
def _spawn_trap(self):
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-c", self._TRAP],
|
|
stdout=subprocess.PIPE, text=True,
|
|
)
|
|
# Wait until the handler is installed before returning.
|
|
line = proc.stdout.readline()
|
|
assert line.strip() == "ready", "trap process failed to start"
|
|
return proc
|
|
|
|
def test_sigterm_ignoring_daemon_is_sigkilled(self, monkeypatch):
|
|
monkeypatch.setattr(ProcessRegistry, "_daemon_term_grace_seconds",
|
|
staticmethod(lambda: 0.3))
|
|
proc = self._spawn_trap()
|
|
try:
|
|
ProcessRegistry._terminate_host_pid(proc.pid)
|
|
assert _wait_until(lambda: proc.poll() is not None, timeout=4.0), \
|
|
"SIGTERM-ignoring daemon should be SIGKILLed after grace"
|
|
finally:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
def test_escalation_does_not_bypass_recycled_pid_guard(self, monkeypatch):
|
|
"""A start-time mismatch must still spare the PID — no SIGTERM, no SIGKILL."""
|
|
monkeypatch.setattr(ProcessRegistry, "_daemon_term_grace_seconds",
|
|
staticmethod(lambda: 0.3))
|
|
proc = self._spawn_trap()
|
|
try:
|
|
real_start = ProcessRegistry._safe_host_start_time(proc.pid)
|
|
ProcessRegistry._terminate_host_pid(
|
|
proc.pid, expected_start=(real_start or 0) + 1)
|
|
assert not _wait_until(lambda: proc.poll() is not None, timeout=0.3)
|
|
assert proc.poll() is None
|
|
finally:
|
|
proc.kill()
|
|
proc.wait()
|
|
|
|
def test_grace_reader_floors_at_zero(self, monkeypatch):
|
|
"""A negative configured grace is clamped to 0 (no escalation)."""
|
|
import hermes_cli.config as cfg_mod
|
|
monkeypatch.setattr(cfg_mod, "read_raw_config",
|
|
lambda: {"terminal": {"daemon_term_grace_seconds": -5}})
|
|
assert ProcessRegistry._daemon_term_grace_seconds() == 0.0
|
|
|
|
@pytest.mark.live_system_guard_bypass
|
|
def test_entire_tree_is_sigkilled_not_just_parent(self, monkeypatch):
|
|
"""A SIGTERM-ignoring parent + children are ALL force-killed.
|
|
|
|
Regression: an earlier implementation trusted psutil.wait_procs's
|
|
gone/alive partition, which mis-partitioned across a parent/child tree
|
|
and left survivors un-killed (flaky — sometimes the parent lived,
|
|
sometimes a child). The escalation now re-probes every target directly.
|
|
"""
|
|
import psutil
|
|
# 2.0s grace (not 1.0): with three interpreters mid-startup on a
|
|
# loaded runner, a 1s SIGTERM->partition window races child spawn and
|
|
# is how a child PID escaped the live-system guard in CI.
|
|
monkeypatch.setattr(ProcessRegistry, "_daemon_term_grace_seconds",
|
|
staticmethod(lambda: 2.0))
|
|
# Parent spawns 2 children; all trap SIGTERM. Parent prints child pids
|
|
# after the handler is installed.
|
|
parent_src = (
|
|
"import signal, subprocess, sys, time;"
|
|
"child='import signal,time\\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\\n"
|
|
"[time.sleep(0.2) for _ in iter(int,1)]';"
|
|
"kids=[subprocess.Popen([sys.executable,'-c',child]) for _ in range(2)];"
|
|
"signal.signal(signal.SIGTERM, signal.SIG_IGN);"
|
|
"sys.stdout.write(' '.join(str(k.pid) for k in kids)+'\\n'); sys.stdout.flush();"
|
|
"[time.sleep(0.2) for _ in iter(int,1)]"
|
|
)
|
|
parent = subprocess.Popen([sys.executable, "-c", parent_src],
|
|
stdout=subprocess.PIPE, text=True)
|
|
# Bound the readline: if the parent wedges before printing, fail THIS
|
|
# test with a clear message instead of letting the per-file timeout
|
|
# SIGKILL the whole pytest process (opaque rc=124 in CI).
|
|
import select as _select
|
|
ready, _, _ = _select.select([parent.stdout], [], [], 20.0)
|
|
assert ready, "parent process failed to print child pids within 20s"
|
|
child_pids = [int(x) for x in parent.stdout.readline().split()]
|
|
all_pids = [parent.pid] + child_pids
|
|
try:
|
|
ProcessRegistry._terminate_host_pid(parent.pid)
|
|
|
|
def _pid_dead(p: int) -> bool:
|
|
# A pid is "dead" for our purposes if it no longer exists OR
|
|
# exists only as an unreaped zombie (already terminated, just
|
|
# not reaped by its reparented parent yet). psutil can also
|
|
# raise mid-probe if the pid vanishes between the existence
|
|
# check and the status read — treat any such race as dead.
|
|
try:
|
|
if not psutil.pid_exists(p):
|
|
return True
|
|
return not ProcessRegistry._proc_alive(psutil.Process(p))
|
|
except Exception:
|
|
return True
|
|
|
|
def _all_dead():
|
|
return all(_pid_dead(p) for p in all_pids)
|
|
|
|
# _terminate_host_pid SIGKILLs synchronously before returning, so
|
|
# the kill signals are already delivered here. The only remaining
|
|
# wait is the kernel tearing down 3 processes and the reparented
|
|
# children transitioning to zombie — which can lag on a loaded CI
|
|
# runner. Give a generous budget (matches the wait() test's 10s)
|
|
# so this asserts the escalation BEHAVIOR, not the runner's
|
|
# scheduling latency. The assertion itself never weakens: every
|
|
# tree member must end up dead/zombie.
|
|
assert _wait_until(_all_dead, timeout=15.0, interval=0.02), (
|
|
"entire SIGTERM-ignoring tree (parent + children) must be SIGKILLed"
|
|
)
|
|
finally:
|
|
for p in all_pids:
|
|
try:
|
|
os.kill(p, signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
pass
|
|
parent.wait()
|
|
|
|
|
|
class TestHandleProcessRedaction:
|
|
"""`_handle_process` redacts background-process output before it reaches the
|
|
model / session.db / CLI display — issue #43025.
|
|
|
|
Mirrors the foreground `terminal` redaction so the two surfaces can't
|
|
diverge. Env-dump commands (`printenv`/`env`) get the ENV-assignment pass
|
|
so opaque tokens are masked; other commands stay on the code_file path.
|
|
"""
|
|
|
|
def _setup(self, monkeypatch, command, output):
|
|
import agent.redact as _r
|
|
monkeypatch.setattr(_r, "_REDACT_ENABLED", True)
|
|
from tools import process_registry as pr
|
|
reg = ProcessRegistry()
|
|
sess = _make_session(sid="proc_redact1", command=command)
|
|
sess.output_buffer = output
|
|
sess.exited = True
|
|
sess.exit_code = 0
|
|
reg._running.clear()
|
|
reg._finished[sess.id] = sess
|
|
reg._running[sess.id] = sess
|
|
monkeypatch.setattr(pr, "process_registry", reg)
|
|
return pr, sess
|
|
|
|
def test_log_redacts_env_dump_opaque_token(self, monkeypatch):
|
|
pr, sess = self._setup(
|
|
monkeypatch, "printenv",
|
|
"MY_SERVICE_TOKEN=abc123randomopaquetokenvalue999\nHOME=/home/u",
|
|
)
|
|
out = json.loads(pr._handle_process({"action": "log", "session_id": sess.id}))
|
|
assert "abc123randomopaquetokenvalue999" not in out["output"]
|
|
assert "HOME=/home/u" in out["output"]
|
|
|
|
def test_poll_redacts_prefix_key(self, monkeypatch):
|
|
pr, sess = self._setup(
|
|
monkeypatch, "python app.py",
|
|
"leaked OPENAI_API_KEY sk-proj-abc123def456ghi789jkl012 here",
|
|
)
|
|
out = json.loads(pr._handle_process({"action": "poll", "session_id": sess.id}))
|
|
assert "abc123def456" not in out["output_preview"]
|
|
|
|
def test_list_redacts_command_and_output(self, monkeypatch):
|
|
"""`process(action=list)` redacts command + output_preview — issue #77484.
|
|
|
|
The list branch previously returned raw ``command[:200]`` and
|
|
``output_preview[-200:]`` with no redaction wrap, leaking inline
|
|
secrets (unlike poll/log/wait/kill).
|
|
"""
|
|
pr, sess = self._setup(
|
|
monkeypatch, "curl -H 'Authorization: Bearer sk-abc123def456ghi789jkl012345'",
|
|
"opaque token sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGG output",
|
|
)
|
|
out = json.loads(pr._handle_process({"action": "list"}))
|
|
assert len(out["processes"]) >= 1
|
|
entry = out["processes"][0]
|
|
assert "sk-abc123def456ghi789jkl012345" not in entry["command"]
|
|
assert "sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGG" not in entry["output_preview"]
|
|
assert "curl" in entry["command"]
|
|
|
|
def test_disabled_passes_through(self, monkeypatch):
|
|
import agent.redact as _r
|
|
monkeypatch.setattr(_r, "_REDACT_ENABLED", False)
|
|
from tools import process_registry as pr
|
|
reg = ProcessRegistry()
|
|
sess = _make_session(sid="proc_redact2", command="printenv")
|
|
sess.output_buffer = "CUSTOM_TOKEN=zzzopaque1234567890abcdef"
|
|
sess.exited = True
|
|
sess.exit_code = 0
|
|
reg._running[sess.id] = sess
|
|
monkeypatch.setattr(pr, "process_registry", reg)
|
|
out = json.loads(pr._handle_process({"action": "log", "session_id": sess.id}))
|
|
assert "zzzopaque1234567890abcdef" in out["output"]
|
|
|
|
|
|
# =========================================================================
|
|
# Reader loop: orphaned grandchild holding the stdout pipe (issue #68915)
|
|
# =========================================================================
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: select() on pipes")
|
|
class TestReaderLoopOrphanedPipe:
|
|
"""Regression tests for issue #68915.
|
|
|
|
When an agent command backgrounds a long-lived process (``node server.js
|
|
&``), the grandchild inherits the write end of the reader's stdout pipe.
|
|
The direct bash child exits, but the pipe never EOFs — the old blocking
|
|
``read1()`` parked the reader thread forever, ``session.exited`` never
|
|
flipped on its own, and ``notify_on_complete`` never fired. The reader
|
|
must instead terminate shortly after the direct child exits, even while
|
|
a descendant still holds the pipe open.
|
|
"""
|
|
|
|
def test_reader_exits_when_orphan_holds_pipe(self, registry):
|
|
"""Reader loop must return promptly after the direct child exits,
|
|
even though a backgrounded descendant keeps the pipe open."""
|
|
proc = subprocess.Popen(
|
|
["sh", "-c", "echo started; sleep 30 & exit 0"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
preexec_fn=os.setsid,
|
|
)
|
|
s = _make_session(sid="proc_orphan_reader")
|
|
s.process = proc
|
|
s.pid = proc.pid
|
|
registry._running[s.id] = s
|
|
|
|
done = threading.Event()
|
|
|
|
def _run():
|
|
registry._reader_loop(s)
|
|
done.set()
|
|
|
|
t = threading.Thread(target=_run, daemon=True)
|
|
t.start()
|
|
try:
|
|
# The direct child exits immediately; the reader must notice and
|
|
# return well before the 30s descendant releases the pipe.
|
|
assert done.wait(timeout=10.0), (
|
|
"_reader_loop is still blocked on the orphan-held pipe "
|
|
"(issue #68915) — session.exited would never flip and "
|
|
"notify_on_complete would never fire"
|
|
)
|
|
assert s.exited is True
|
|
assert s.exit_code == 0
|
|
assert s.completion_reason == "exited"
|
|
assert "started" in s.output_buffer
|
|
assert s.id in registry._finished
|
|
finally:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
def test_reader_exit_fires_notify_on_complete(self, registry):
|
|
"""The autonomous completion notification must not depend on a
|
|
poll()/wait() call when an orphan holds the pipe."""
|
|
proc = subprocess.Popen(
|
|
["sh", "-c", "sleep 30 & echo bg-started"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
preexec_fn=os.setsid,
|
|
)
|
|
s = _make_session(sid="proc_orphan_notify")
|
|
s.process = proc
|
|
s.pid = proc.pid
|
|
s.notify_on_complete = True
|
|
registry._running[s.id] = s
|
|
|
|
done = threading.Event()
|
|
|
|
def _run():
|
|
registry._reader_loop(s)
|
|
done.set()
|
|
|
|
t = threading.Thread(target=_run, daemon=True)
|
|
t.start()
|
|
try:
|
|
assert done.wait(timeout=10.0), (
|
|
"_reader_loop blocked — completion notification lost (#68915)"
|
|
)
|
|
# Exactly one completion event must have been queued.
|
|
item = registry.completion_queue.get_nowait()
|
|
assert item["type"] == "completion"
|
|
assert item["session_id"] == s.id
|
|
assert item["exit_code"] == 0
|
|
finally:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
# =========================================================================
|
|
# systemd cgroup isolation for gateway-spawned local executors (#70716)
|
|
# =========================================================================
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only: systemd scopes")
|
|
class TestSystemdCgroupIsolation:
|
|
"""Verify spawn_local wraps the worker in ``systemd-run --user --scope``
|
|
when running under a supervisor and systemd-run is available, and falls
|
|
back to the legacy ``start_new_session`` path otherwise.
|
|
|
|
Issue #70716: local background terminal executors inherit the gateway's
|
|
cgroup, so an OOM in a memory-heavy worker lets systemd-oomd kill the
|
|
ENTIRE gateway cgroup, taking down the messaging control plane.
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def _gateway_identity(self, monkeypatch):
|
|
"""Opt-in: mark this test as running AS the live gateway process."""
|
|
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
|
monkeypatch.setattr(
|
|
"gateway.status.get_running_pid",
|
|
lambda *, cleanup_stale=False: os.getpid(),
|
|
)
|
|
|
|
def _fake_popen_capture(self):
|
|
"""Return (fake_popen, captured) where captured["argv"] gets the
|
|
argv passed to subprocess.Popen."""
|
|
captured = {}
|
|
|
|
def fake_popen(argv, **kwargs):
|
|
captured["argv"] = list(argv)
|
|
captured["start_new_session"] = kwargs.get("start_new_session")
|
|
proc = MagicMock()
|
|
proc.pid = 4321
|
|
proc.stdout = iter([])
|
|
proc.stdin = MagicMock()
|
|
proc.poll.return_value = None
|
|
return proc
|
|
|
|
return fake_popen, captured
|
|
|
|
def test_wraps_in_systemd_scope_when_supervisor_and_available(
|
|
self, registry, monkeypatch, _gateway_identity
|
|
):
|
|
"""Under a supervisor with systemd-run available, the spawn argv is
|
|
wrapped in ``systemd-run --user --scope --unit=hermes-worker-<id>``."""
|
|
fake_popen, captured = self._fake_popen_capture()
|
|
|
|
monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash")
|
|
monkeypatch.setattr(
|
|
"tools.process_registry._systemd_run_user_scope_available",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_gateway_supervisor_process",
|
|
lambda: True,
|
|
)
|
|
# _build_systemd_scope_argv calls shutil.which — point it at a stub.
|
|
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"),
|
|
):
|
|
session = registry.spawn_local("echo hello", cwd="/tmp")
|
|
|
|
argv = captured["argv"]
|
|
assert argv[0] == "/usr/bin/systemd-run", argv
|
|
assert "--user" in argv
|
|
assert "--scope" in argv
|
|
assert "--quiet" in argv, (
|
|
"systemd-run argv must include --quiet (#70716 gap #3)"
|
|
)
|
|
assert "--unit" in argv
|
|
unit_idx = argv.index("--unit")
|
|
assert argv[unit_idx + 1].startswith("hermes-worker-"), argv
|
|
assert argv[unit_idx + 1] == f"hermes-worker-{session.id}", (
|
|
argv
|
|
) # _build_systemd_scope_argv uses bare name
|
|
properties = [
|
|
argv[index + 1]
|
|
for index, value in enumerate(argv[:-1])
|
|
if value == "--property"
|
|
]
|
|
assert "MemoryAccounting=yes" in properties
|
|
assert "OOMPolicy=kill" in properties
|
|
memory_max = next(
|
|
value for value in properties if value.startswith("MemoryMax=")
|
|
)
|
|
assert int(memory_max.split("=", 1)[1]) > 0
|
|
# The original shell command must still be present at the tail,
|
|
# after the ``--`` separator that prevents systemd-run from
|
|
# interpreting command flags as its own.
|
|
assert "--" in argv, "systemd-run argv must use -- to separate command"
|
|
sep_idx = argv.index("--")
|
|
assert "/bin/bash" in argv[sep_idx:]
|
|
assert "set +m; echo hello" in argv[sep_idx:]
|
|
# systemd-run --scope gives the worker a new cgroup but NOT a new
|
|
# session (#70716 regression: start_new_session was False, so the
|
|
# worker kept the parent's session + controlling terminal → SIGTTIN/
|
|
# SIGTTOU stopped the TUI). start_new_session=True gives systemd-run
|
|
# (and the scoped worker below it) a private session.
|
|
assert captured["start_new_session"] is True
|
|
# The session must record the unit name so kill_process can stop it.
|
|
assert session.systemd_unit == f"hermes-worker-{session.id}.scope"
|
|
|
|
def test_falls_back_when_systemd_run_unavailable(self, registry, monkeypatch, _gateway_identity):
|
|
"""Under a supervisor but without systemd-run, fall back to the
|
|
legacy ``start_new_session=True`` path (worker shares the gateway
|
|
cgroup)."""
|
|
fake_popen, captured = self._fake_popen_capture()
|
|
|
|
monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash")
|
|
monkeypatch.setattr(
|
|
"tools.process_registry._systemd_run_user_scope_available",
|
|
lambda: False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_gateway_supervisor_process",
|
|
lambda: True,
|
|
)
|
|
|
|
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")
|
|
|
|
argv = captured["argv"]
|
|
# No systemd-run wrapping — direct shell invocation.
|
|
assert argv == ["/bin/bash", "-lic", "set +m; echo hello"], argv
|
|
assert captured["start_new_session"] is True
|
|
|
|
def test_falls_back_when_not_under_supervisor(self, registry, monkeypatch):
|
|
"""CLI mode (no supervisor) must NOT wrap in a systemd scope even if
|
|
systemd-run is available — isolation is a gateway concern."""
|
|
fake_popen, captured = self._fake_popen_capture()
|
|
|
|
monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash")
|
|
monkeypatch.setattr(
|
|
"tools.process_registry._systemd_run_user_scope_available",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_gateway_supervisor_process",
|
|
lambda: False,
|
|
)
|
|
|
|
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")
|
|
|
|
argv = captured["argv"]
|
|
assert argv == ["/bin/bash", "-lic", "set +m; echo hello"], argv
|
|
assert captured["start_new_session"] is True
|
|
|
|
@pytest.mark.parametrize("use_pty", [False, True])
|
|
def test_inherited_systemd_marker_does_not_scope_interactive_cli(
|
|
self, registry, monkeypatch, use_pty
|
|
):
|
|
"""A CLI inside a supervised terminal must keep workers off its tty.
|
|
|
|
INVOCATION_ID is inherited by every descendant, so its presence
|
|
alone must not activate the gateway-only systemd scope path.
|
|
"""
|
|
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")
|
|
|
|
if use_pty:
|
|
from ptyprocess import PtyProcess
|
|
|
|
fake_pty = MagicMock(pid=4321)
|
|
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",
|
|
]
|
|
else:
|
|
fake_popen, captured = self._fake_popen_capture()
|
|
with (
|
|
patch("subprocess.Popen", side_effect=fake_popen),
|
|
patch("threading.Thread", return_value=MagicMock()),
|
|
patch.object(registry, "_write_checkpoint"),
|
|
):
|
|
session = registry.spawn_local("echo hello", cwd="/tmp")
|
|
assert captured["argv"] == [
|
|
"/bin/bash", "-lic", "set +m; echo hello",
|
|
]
|
|
assert captured["start_new_session"] is True
|
|
|
|
assert session.systemd_unit == ""
|
|
|
|
@pytest.mark.parametrize("use_pty", [False, True])
|
|
def test_inherited_gateway_tree_markers_do_not_scope_child_cli(
|
|
self, registry, monkeypatch, use_pty
|
|
):
|
|
"""Gateway descendants are not the gateway process that owns the PID file.
|
|
|
|
_HERMES_GATEWAY is inherited (and set by importing gateway.run), so
|
|
both it and INVOCATION_ID may be present in a child process. The
|
|
PID-ownership gate must still keep the scope path off.
|
|
"""
|
|
monkeypatch.setenv("INVOCATION_ID", "inherited-systemd-marker")
|
|
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
|
monkeypatch.setattr(
|
|
"gateway.status.get_running_pid",
|
|
lambda *, cleanup_stale=False: os.getpid() + 1,
|
|
)
|
|
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")
|
|
|
|
if use_pty:
|
|
from ptyprocess import PtyProcess
|
|
|
|
fake_pty = MagicMock(pid=4321)
|
|
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",
|
|
]
|
|
else:
|
|
fake_popen, captured = self._fake_popen_capture()
|
|
with (
|
|
patch("subprocess.Popen", side_effect=fake_popen),
|
|
patch("threading.Thread", return_value=MagicMock()),
|
|
patch.object(registry, "_write_checkpoint"),
|
|
):
|
|
session = registry.spawn_local("echo hello", cwd="/tmp")
|
|
assert captured["argv"] == [
|
|
"/bin/bash", "-lic", "set +m; echo hello",
|
|
]
|
|
assert captured["start_new_session"] is True
|
|
|
|
assert session.systemd_unit == ""
|
|
|
|
def test_systemd_post_spawn_failure_never_kills_gateway_process_group(
|
|
self, registry, monkeypatch, _gateway_identity
|
|
):
|
|
"""Cleanup must not killpg: scope teardown is the authoritative path."""
|
|
fake_popen, _captured = self._fake_popen_capture()
|
|
fake_proc = fake_popen(["placeholder"])
|
|
|
|
monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash")
|
|
monkeypatch.setattr(
|
|
"tools.process_registry._systemd_run_user_scope_available",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_gateway_supervisor_process",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run")
|
|
|
|
broken_reader = MagicMock()
|
|
broken_reader.start.side_effect = RuntimeError("reader failed")
|
|
|
|
with patch("subprocess.Popen", return_value=fake_proc), \
|
|
patch("threading.Thread", return_value=broken_reader), \
|
|
patch("tools.process_registry._stop_systemd_unit", return_value=True) as stop_unit, \
|
|
patch("os.killpg") as killpg, \
|
|
patch.object(registry, "_write_checkpoint"):
|
|
with pytest.raises(RuntimeError, match="reader failed"):
|
|
registry.spawn_local("echo hello", cwd="/tmp")
|
|
|
|
stop_unit.assert_called_once()
|
|
assert stop_unit.call_args.args[0].startswith("hermes-worker-proc_")
|
|
assert stop_unit.call_args.args[0].endswith(".scope")
|
|
killpg.assert_not_called()
|
|
|
|
def test_pty_spawn_is_wrapped_in_systemd_scope(self, registry, monkeypatch, _gateway_identity):
|
|
"""Interactive executors receive the same sibling-cgroup isolation."""
|
|
from ptyprocess import PtyProcess
|
|
|
|
fake_pty = MagicMock()
|
|
fake_pty.pid = 4321
|
|
|
|
monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash")
|
|
monkeypatch.setattr(
|
|
"tools.process_registry._systemd_run_user_scope_available",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_gateway_supervisor_process",
|
|
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)
|
|
|
|
argv = pty_spawn.call_args.args[0]
|
|
assert argv[0] == "/usr/bin/systemd-run"
|
|
assert "--scope" in argv
|
|
assert "--unit" in argv
|
|
assert "--" in argv
|
|
assert argv[-3:] == ["/bin/bash", "-lic", "set +m; codex"]
|
|
assert session.systemd_unit == f"hermes-worker-{session.id}.scope"
|
|
|
|
def test_pty_spawn_failure_reaps_scope_before_distinct_pipe_fallback(
|
|
self, registry, monkeypatch, _gateway_identity
|
|
):
|
|
"""A failed PTY scope must not collide with the pipe fallback scope."""
|
|
from ptyprocess import PtyProcess
|
|
|
|
events = []
|
|
fake_proc = MagicMock()
|
|
fake_proc.pid = 4321
|
|
fake_proc.stdout = iter([])
|
|
fake_proc.stdin = MagicMock()
|
|
fake_proc.poll.return_value = None
|
|
|
|
def fake_popen(argv, **_kwargs):
|
|
events.append(("pipe", list(argv)))
|
|
return fake_proc
|
|
|
|
def fake_stop(unit_name):
|
|
events.append(("stop", unit_name))
|
|
return True
|
|
|
|
def fail_pty(*_args, **_kwargs):
|
|
events.append(("pty", None))
|
|
raise RuntimeError("PTY wrapper failed after scope creation")
|
|
|
|
monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash")
|
|
monkeypatch.setattr(
|
|
"tools.process_registry._systemd_run_user_scope_available",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_gateway_supervisor_process",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run")
|
|
|
|
with patch.object(PtyProcess, "spawn", side_effect=fail_pty), \
|
|
patch("subprocess.Popen", side_effect=fake_popen), \
|
|
patch("tools.process_registry._stop_systemd_unit", side_effect=fake_stop), \
|
|
patch("threading.Thread", return_value=MagicMock()), \
|
|
patch.object(registry, "_write_checkpoint"):
|
|
session = registry.spawn_local("codex", cwd="/tmp", use_pty=True)
|
|
|
|
assert [event[0] for event in events] == ["pty", "stop", "pipe"]
|
|
stopped_unit = events[1][1]
|
|
fallback_argv = events[2][1]
|
|
assert stopped_unit == f"hermes-worker-{session.id}.scope"
|
|
unit_idx = fallback_argv.index("--unit")
|
|
assert fallback_argv[unit_idx + 1] == (
|
|
f"hermes-worker-{session.id}-pipe-fallback"
|
|
)
|
|
assert session.systemd_unit == (
|
|
f"hermes-worker-{session.id}-pipe-fallback.scope"
|
|
)
|
|
|
|
def test_pty_spawn_failure_does_not_fallback_when_scope_reap_fails(
|
|
self, registry, monkeypatch, _gateway_identity
|
|
):
|
|
"""Do not launch a duplicate command while the failed PTY scope may live."""
|
|
from ptyprocess import PtyProcess
|
|
|
|
monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash")
|
|
monkeypatch.setattr(
|
|
"tools.process_registry._systemd_run_user_scope_available",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_gateway_supervisor_process",
|
|
lambda: True,
|
|
)
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run")
|
|
|
|
with patch.object(
|
|
PtyProcess,
|
|
"spawn",
|
|
side_effect=RuntimeError("PTY wrapper failed after scope creation"),
|
|
), patch("subprocess.Popen") as pipe_spawn, patch(
|
|
"tools.process_registry._stop_systemd_unit", return_value=False
|
|
) as stop_unit:
|
|
with pytest.raises(RuntimeError, match="could not be reaped"):
|
|
registry.spawn_local("codex", cwd="/tmp", use_pty=True)
|
|
|
|
stop_unit.assert_called_once()
|
|
pipe_spawn.assert_not_called()
|
|
|
|
def test_worker_memory_limit_honors_local_guard_mb_override(self, monkeypatch):
|
|
import tools.process_registry as pr
|
|
|
|
monkeypatch.setenv("TERMINAL_LOCAL_MEMORY_MAX_MB", "123")
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run")
|
|
|
|
with patch("tools.process_registry.logger.warning") as warning:
|
|
argv = pr._build_systemd_scope_argv(
|
|
["/bin/bash", "-lc", "true"],
|
|
unit_suffix="test",
|
|
)
|
|
|
|
warning.assert_not_called()
|
|
assert f"MemoryMax={123 * 1024 * 1024}" in argv
|
|
|
|
def test_worker_memory_limit_caps_oversized_local_guard_override(
|
|
self, monkeypatch
|
|
):
|
|
import tools.process_registry as pr
|
|
|
|
monkeypatch.setenv("TERMINAL_LOCAL_MEMORY_MAX_MB", "999999")
|
|
monkeypatch.setattr(
|
|
pr.Path,
|
|
"read_text",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("no cgroup")),
|
|
)
|
|
monkeypatch.setattr(
|
|
pr.os,
|
|
"sysconf",
|
|
lambda *_args: (_ for _ in ()).throw(OSError("no sysconf")),
|
|
)
|
|
|
|
assert pr._worker_memory_max_bytes() == pr._DEFAULT_WORKER_MEMORY_MAX_BYTES
|
|
|
|
def test_kill_recovered_detached_already_exited_stops_persisted_scope(
|
|
self, registry, monkeypatch
|
|
):
|
|
"""Recovered detached sessions whose wrapper PID is gone/recycled must
|
|
still stop their persisted systemd scope before the already_exited
|
|
return, while retaining the PID-reuse guard (no PID tree kill)."""
|
|
session = _make_session(sid="proc_recovered_scope", command="daemonize")
|
|
session.detached = True
|
|
session.pid_scope = "host"
|
|
session.pid = 12345
|
|
session.host_start_time = 67890
|
|
session.systemd_unit = "hermes-worker-proc_recovered_scope.scope"
|
|
registry._running[session.id] = session
|
|
|
|
stopped = []
|
|
terminated = []
|
|
monkeypatch.setattr(registry, "_host_pid_is_ours", lambda pid, start: False)
|
|
monkeypatch.setattr(registry, "_terminate_host_pid", lambda pid, start: terminated.append((pid, start)))
|
|
monkeypatch.setattr("tools.process_registry._stop_systemd_unit", lambda unit: stopped.append(unit) or True)
|
|
|
|
with patch.object(registry, "_write_checkpoint"):
|
|
result = registry.kill_process(session.id)
|
|
|
|
assert result["status"] == "already_exited"
|
|
assert stopped == ["hermes-worker-proc_recovered_scope.scope"]
|
|
assert terminated == []
|
|
assert session.exited is True
|
|
assert session.id in registry._finished
|
|
assert session.id not in registry._running
|
|
|
|
def test_systemd_run_user_scope_available_caches_after_probe(
|
|
self, registry, monkeypatch
|
|
):
|
|
"""The availability check probes once and caches — a second call must
|
|
not re-probe (and must return the same value)."""
|
|
import tools.process_registry as pr
|
|
|
|
# Reset the cache.
|
|
monkeypatch.setattr(pr, "_SYSTEMD_SCOPE_AVAILABLE", None)
|
|
probe_calls = []
|
|
|
|
def fake_run(*args, **kwargs):
|
|
probe_calls.append(args)
|
|
return subprocess.CompletedProcess(args=args[0], returncode=0)
|
|
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run")
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
first = pr._systemd_run_user_scope_available()
|
|
second = pr._systemd_run_user_scope_available()
|
|
assert first is True
|
|
assert second is True
|
|
assert len(probe_calls) == 1, "probe must run only once (cached)"
|
|
|
|
def test_systemd_scope_first_probe_is_serialized(self, monkeypatch):
|
|
"""Concurrent first-use callers must wait for one definitive probe.
|
|
|
|
A temporary cached ``False`` would let a racing worker spawn inside the
|
|
gateway cgroup, defeating the OOM isolation guarantee.
|
|
"""
|
|
import tools.process_registry as pr
|
|
|
|
monkeypatch.setattr(pr, "_SYSTEMD_SCOPE_AVAILABLE", None)
|
|
probe_started = threading.Event()
|
|
release_probe = threading.Event()
|
|
probe_calls = []
|
|
results = []
|
|
|
|
def fake_run(*args, **kwargs):
|
|
probe_calls.append(args)
|
|
probe_started.set()
|
|
assert release_probe.wait(timeout=2)
|
|
return subprocess.CompletedProcess(args=args[0], returncode=0)
|
|
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run")
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
first = threading.Thread(
|
|
target=lambda: results.append(pr._systemd_run_user_scope_available())
|
|
)
|
|
second = threading.Thread(
|
|
target=lambda: results.append(pr._systemd_run_user_scope_available())
|
|
)
|
|
first.start()
|
|
assert probe_started.wait(timeout=2)
|
|
second.start()
|
|
|
|
# The racing caller must be blocked behind the probe, not observe a
|
|
# temporary False cache value.
|
|
second.join(timeout=0.05)
|
|
assert second.is_alive()
|
|
|
|
release_probe.set()
|
|
first.join(timeout=2)
|
|
second.join(timeout=2)
|
|
|
|
assert not first.is_alive()
|
|
assert not second.is_alive()
|
|
assert results == [True, True]
|
|
assert len(probe_calls) == 1
|
|
|
|
def test_failed_systemd_probe_retries_after_cache_ttl(self, monkeypatch):
|
|
import tools.process_registry as pr
|
|
|
|
monkeypatch.setattr(pr, "_SYSTEMD_SCOPE_AVAILABLE", None)
|
|
monkeypatch.setattr(pr, "_SYSTEMD_SCOPE_PROBED_AT", 0.0, raising=False)
|
|
clock = [100.0]
|
|
probe_results = [1, 0]
|
|
probe_calls = []
|
|
|
|
def fake_run(*args, **kwargs):
|
|
probe_calls.append(args)
|
|
return subprocess.CompletedProcess(
|
|
args=args[0], returncode=probe_results.pop(0)
|
|
)
|
|
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run")
|
|
monkeypatch.setattr("tools.process_registry.time.monotonic", lambda: clock[0])
|
|
monkeypatch.setattr("subprocess.run", fake_run)
|
|
|
|
assert pr._systemd_run_user_scope_available() is False
|
|
assert pr._systemd_run_user_scope_available() is False
|
|
assert len(probe_calls) == 1
|
|
|
|
clock[0] += 61
|
|
assert pr._systemd_run_user_scope_available() is True
|
|
assert len(probe_calls) == 2
|
|
|
|
def test_stop_systemd_unit_treats_absent_unit_as_clean(self, monkeypatch):
|
|
import tools.process_registry as pr
|
|
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemctl")
|
|
monkeypatch.setattr(
|
|
"subprocess.run",
|
|
lambda *args, **kwargs: subprocess.CompletedProcess(
|
|
args=args[0],
|
|
returncode=5,
|
|
stderr=b"Unit hermes-worker-gone.scope not loaded.\n",
|
|
),
|
|
)
|
|
|
|
assert pr._stop_systemd_unit("hermes-worker-gone.scope") is True
|
|
|
|
def test_darwin_never_takes_scope_path_even_with_systemd_run_on_path(
|
|
self, registry, monkeypatch, _gateway_identity
|
|
):
|
|
"""macOS no-op guarantee (#70716 cross-platform audit).
|
|
|
|
With ``_IS_LINUX = False`` (darwin), the spawn path must be
|
|
byte-identical to the legacy path even when a ``systemd-run``
|
|
binary is somehow on PATH and the gateway identity checks pass:
|
|
no probe, no wrapping, no unit recorded.
|
|
"""
|
|
import tools.process_registry as pr
|
|
|
|
fake_popen, captured = self._fake_popen_capture()
|
|
|
|
monkeypatch.setattr(pr, "_IS_LINUX", False)
|
|
monkeypatch.setattr(pr, "_IS_WINDOWS", False)
|
|
monkeypatch.setattr(pr, "_SYSTEMD_SCOPE_AVAILABLE", None)
|
|
monkeypatch.setattr("tools.process_registry._find_shell", lambda: "/bin/bash")
|
|
monkeypatch.setattr(
|
|
"gateway.restart.is_gateway_supervisor_process", lambda: True
|
|
)
|
|
# If any branch consults the probe or builds a scope argv on darwin,
|
|
# fail loudly.
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/local/bin/systemd-run")
|
|
scope_builds = []
|
|
real_build = pr._build_systemd_scope_argv
|
|
monkeypatch.setattr(
|
|
pr,
|
|
"_build_systemd_scope_argv",
|
|
lambda *a, **k: scope_builds.append(a) or real_build(*a, **k),
|
|
)
|
|
probe_runs = []
|
|
|
|
def fake_probe_run(argv, **kwargs):
|
|
probe_runs.append(argv)
|
|
return subprocess.CompletedProcess(args=argv, returncode=0)
|
|
|
|
monkeypatch.setattr("subprocess.run", fake_probe_run)
|
|
|
|
with (
|
|
patch("subprocess.Popen", side_effect=fake_popen),
|
|
patch("threading.Thread", return_value=MagicMock()),
|
|
patch.object(registry, "_write_checkpoint"),
|
|
):
|
|
session = registry.spawn_local("echo hello", cwd="/tmp")
|
|
|
|
argv = captured["argv"]
|
|
assert argv == ["/bin/bash", "-lic", "set +m; echo hello"], argv
|
|
assert captured["start_new_session"] is True
|
|
assert session.systemd_unit == ""
|
|
assert scope_builds == [], "darwin must never build a systemd scope argv"
|
|
assert probe_runs == [], "darwin must never run the systemd-run probe"
|
|
|
|
def test_probe_returns_false_off_linux(self, monkeypatch):
|
|
"""``_systemd_run_user_scope_available`` is False on non-Linux even
|
|
when a ``systemd-run`` binary exists on PATH."""
|
|
import tools.process_registry as pr
|
|
|
|
monkeypatch.setattr(pr, "_IS_LINUX", False)
|
|
monkeypatch.setattr(pr, "_SYSTEMD_SCOPE_AVAILABLE", None)
|
|
monkeypatch.setattr("shutil.which", lambda name: "/usr/local/bin/systemd-run")
|
|
probe_runs = []
|
|
monkeypatch.setattr(
|
|
"subprocess.run",
|
|
lambda argv, **kwargs: probe_runs.append(argv)
|
|
or subprocess.CompletedProcess(args=argv, returncode=0),
|
|
)
|
|
|
|
assert pr._systemd_run_user_scope_available() is False
|
|
assert probe_runs == [], "non-Linux must not exec the probe"
|
|
|
|
|
|
class TestNotificationRedaction:
|
|
"""Background-process notification delivery (completion_queue) applies the
|
|
same redaction as the explicit process tool — issue #43025 gap.
|
|
|
|
The _move_to_finished() and _check_watch_patterns() paths enqueue raw
|
|
output into the completion_queue. After the fix, _redact_process_result()
|
|
is called before enqueueing so secrets are masked in the [IMPORTANT: ...]
|
|
messages delivered to the LLM.
|
|
"""
|
|
|
|
def test_completion_notification_redacts_secret(self, monkeypatch):
|
|
"""_move_to_finished completion notification redacts API keys."""
|
|
import agent.redact as _r
|
|
monkeypatch.setattr(_r, "_REDACT_ENABLED", True)
|
|
from tools import process_registry as pr
|
|
|
|
reg = ProcessRegistry()
|
|
sess = _make_session(sid="proc_notif1", command="env")
|
|
sess.output_buffer = "OPENAI_API_KEY=sk-proj-secret123\nHOME=/home/u"
|
|
sess.notify_on_complete = True
|
|
sess.exited = True
|
|
sess.exit_code = 0
|
|
reg._running[sess.id] = sess
|
|
monkeypatch.setattr(pr, "process_registry", reg)
|
|
|
|
reg._move_to_finished(sess)
|
|
|
|
# Drain and check the notification
|
|
results = reg.drain_notifications()
|
|
assert len(results) == 1
|
|
_evt, text = results[0]
|
|
assert "sk-proj-secret123" not in text
|
|
assert "REDACTED" in text or "sk-proj" not in text
|
|
|
|
def test_watch_match_notification_redacts_secret(self, monkeypatch):
|
|
"""_check_watch_patterns watch_match notification redacts secrets."""
|
|
import agent.redact as _r
|
|
monkeypatch.setattr(_r, "_REDACT_ENABLED", True)
|
|
from tools import process_registry as pr
|
|
|
|
reg = ProcessRegistry()
|
|
sess = _make_session(sid="proc_notif2", command="python server.py")
|
|
sess.output_buffer = "Server started\nAPI_TOKEN=ghp_abc123def456\nListening on :8080"
|
|
sess.watch_patterns = ["API_TOKEN"]
|
|
sess._watch_disabled = False
|
|
sess._watch_hits = 0
|
|
sess._watch_suppressed = 0
|
|
sess.watcher_platform = None
|
|
sess.watcher_chat_id = None
|
|
sess.watcher_user_id = None
|
|
sess.watcher_user_name = None
|
|
sess.watcher_thread_id = None
|
|
sess.watcher_message_id = None
|
|
sess.exited = False
|
|
reg._running[sess.id] = sess
|
|
monkeypatch.setattr(pr, "process_registry", reg)
|
|
|
|
reg._check_watch_patterns(sess, "API_TOKEN=ghp_abc123def456\n")
|
|
|
|
results = reg.drain_notifications()
|
|
assert len(results) == 1
|
|
_evt, text = results[0]
|
|
assert "ghp_abc123def456" not in text
|
|
assert "ghp_" not in text or "REDACTED" in text
|
|
|
|
|
|
# ── Prefix resolution (Factory Droid-inspired task-ID prefixes) ──────────────
|
|
|
|
|
|
class TestGetByPrefix:
|
|
"""ProcessRegistry.get() resolves unique ID prefixes like git short hashes."""
|
|
|
|
def test_full_id_still_exact(self, registry):
|
|
s = _make_session(sid="proc_4dae56ca81f6")
|
|
registry._running[s.id] = s
|
|
assert registry.get("proc_4dae56ca81f6") is s
|
|
|
|
def test_unique_prefix_resolves(self, registry):
|
|
s = _make_session(sid="proc_4dae56ca81f6")
|
|
registry._running[s.id] = s
|
|
assert registry.get("proc_4dae5") is s
|
|
|
|
def test_bare_suffix_resolves(self, registry):
|
|
s = _make_session(sid="proc_4dae56ca81f6")
|
|
registry._running[s.id] = s
|
|
assert registry.get("4dae56") is s
|
|
|
|
def test_finished_sessions_also_resolve(self, registry):
|
|
s = _make_session(sid="proc_9bee77aa0011", exited=True, exit_code=0)
|
|
registry._finished[s.id] = s
|
|
assert registry.get("proc_9bee") is s
|
|
|
|
def test_ambiguous_prefix_returns_none(self, registry):
|
|
a = _make_session(sid="proc_4dae56ca81f6")
|
|
b = _make_session(sid="proc_4dae99999999")
|
|
registry._running[a.id] = a
|
|
registry._running[b.id] = b
|
|
assert registry.get("proc_4dae") is None
|
|
|
|
def test_too_short_prefix_returns_none(self, registry):
|
|
s = _make_session(sid="proc_4dae56ca81f6")
|
|
registry._running[s.id] = s
|
|
assert registry.get("proc_4da") is None
|
|
assert registry.get("4da") is None
|
|
assert registry.get("proc_") is None
|
|
assert registry.get("") is None
|
|
|
|
def test_exact_id_wins_over_prefix_scan(self, registry):
|
|
# A session whose FULL id happens to be a prefix of another's must
|
|
# resolve to itself, never trigger the ambiguity path.
|
|
short = _make_session(sid="proc_4dae")
|
|
long = _make_session(sid="proc_4dae56ca81f6")
|
|
registry._running[short.id] = short
|
|
registry._running[long.id] = long
|
|
assert registry.get("proc_4dae") is short
|
|
|
|
def test_no_match_returns_none(self, registry):
|
|
s = _make_session(sid="proc_4dae56ca81f6")
|
|
registry._running[s.id] = s
|
|
assert registry.get("proc_ffff") is None
|
|
|
|
def test_poll_accepts_prefix(self, registry):
|
|
s = _make_session(sid="proc_4dae56ca81f6", output="hello world")
|
|
registry._running[s.id] = s
|
|
result = registry.poll("4dae56ca")
|
|
assert result["session_id"] == "proc_4dae56ca81f6"
|
|
assert result["status"] == "running"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config-level model_not_found notice in delegation batch reports (#97654)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_delegation_batch_evt(results):
|
|
"""A batch async-delegation event carrying a per-task ``results`` list."""
|
|
return {
|
|
"type": "async_delegation",
|
|
"delegation_id": "deleg_97654",
|
|
"is_batch": True,
|
|
"results": results,
|
|
"goals": [r.get("goal") or "" for r in results],
|
|
"session_key": "agent:main:cli:dm:local",
|
|
"status": "completed",
|
|
"model": "upstage/solar-pro-4",
|
|
}
|
|
|
|
|
|
def _patch_delegation_config(
|
|
monkeypatch, model="upstage/solar-pro-4", provider="openrouter", **over
|
|
):
|
|
import tools.process_registry as _pr
|
|
|
|
cfg = {"model": model, "provider": provider}
|
|
cfg.update(over)
|
|
monkeypatch.setattr(_pr, "_delegation_config", lambda: cfg)
|
|
return cfg
|
|
|
|
|
|
def _format_async(evt) -> str:
|
|
from tools.process_registry import format_process_notification
|
|
|
|
text = format_process_notification(evt)
|
|
assert text is not None, "format_process_notification returned None"
|
|
return text
|
|
|
|
|
|
def test_model_not_found_notice_single_failure_once(monkeypatch):
|
|
evt = _make_delegation_batch_evt([
|
|
{
|
|
"task_index": 0,
|
|
"status": "failed",
|
|
"exit_reason": "error",
|
|
"goal": "Create bridge module",
|
|
"error": "HTTP 400: upstage/solar-pro-4 is not a valid model ID",
|
|
"summary": "HTTP 400: upstage/solar-pro-4 is not a valid model ID",
|
|
}
|
|
])
|
|
_patch_delegation_config(monkeypatch)
|
|
text = _format_async(evt)
|
|
assert text is not None
|
|
assert text.count("SUBAGENT MODEL REJECTED") == 1
|
|
assert "upstage/solar-pro-4" in text
|
|
assert "openrouter" in text
|
|
assert "No fallback chain is configured" in text
|
|
|
|
|
|
def test_model_not_found_notice_mixed_batch_named_model(monkeypatch):
|
|
evt = _make_delegation_batch_evt([
|
|
{
|
|
"task_index": 0,
|
|
"status": "failed",
|
|
"exit_reason": "error",
|
|
"goal": "A",
|
|
"error": "HTTP 400: upstage/solar-pro-4 is not a valid model ID",
|
|
"summary": "HTTP 400: upstage/solar-pro-4 is not a valid model ID",
|
|
},
|
|
{
|
|
"task_index": 1,
|
|
"status": "completed",
|
|
"goal": "B",
|
|
"summary": "ok",
|
|
"api_calls": 3,
|
|
},
|
|
])
|
|
_patch_delegation_config(monkeypatch)
|
|
text = _format_async(evt)
|
|
assert text.count("SUBAGENT MODEL REJECTED") == 1
|
|
assert "upstage/solar-pro-4" in text
|
|
|
|
|
|
def test_model_not_found_notice_absent_for_non_model_errors(monkeypatch):
|
|
evt = _make_delegation_batch_evt([
|
|
{
|
|
"task_index": 0,
|
|
"status": "failed",
|
|
"goal": "A",
|
|
"error": "HTTP 429: rate limit exceeded",
|
|
},
|
|
{
|
|
"task_index": 1,
|
|
"status": "failed",
|
|
"goal": "B",
|
|
"error": "Connection timed out",
|
|
},
|
|
])
|
|
_patch_delegation_config(monkeypatch)
|
|
text = _format_async(evt)
|
|
assert "SUBAGENT MODEL REJECTED" not in text
|
|
|
|
|
|
def test_model_not_found_notice_absent_when_configured_model_not_named(monkeypatch):
|
|
evt = _make_delegation_batch_evt([
|
|
{
|
|
"task_index": 0,
|
|
"status": "failed",
|
|
"goal": "A",
|
|
"error": "HTTP 400: gpt-99 is not a valid model ID",
|
|
}
|
|
])
|
|
# Configured model is upstage/solar-pro-4; the rejection names gpt-99.
|
|
_patch_delegation_config(monkeypatch)
|
|
text = _format_async(evt)
|
|
assert "SUBAGENT MODEL REJECTED" not in text
|
|
|
|
|
|
def test_model_not_found_notice_single_dispatch(monkeypatch):
|
|
evt = {
|
|
"type": "async_delegation",
|
|
"delegation_id": "deleg_single",
|
|
"session_key": "agent:main:cli:dm:local",
|
|
"goal": "task A",
|
|
"model": "upstage/solar-pro-4",
|
|
"status": "failed",
|
|
"error": "HTTP 400: upstage/solar-pro-4 is not a valid model ID",
|
|
"summary": "HTTP 400: upstage/solar-pro-4 is not a valid model ID",
|
|
}
|
|
_patch_delegation_config(monkeypatch)
|
|
text = _format_async(evt)
|
|
assert text.count("SUBAGENT MODEL REJECTED") == 1
|
|
assert "upstage/solar-pro-4" in text
|
|
|
|
|
|
def test_model_not_found_notice_absent_when_fallback_chain_configured(monkeypatch):
|
|
evt = _make_delegation_batch_evt([
|
|
{
|
|
"task_index": 0,
|
|
"status": "failed",
|
|
"goal": "A",
|
|
"error": "HTTP 400: upstage/solar-pro-4 is not a valid model ID",
|
|
}
|
|
])
|
|
_patch_delegation_config(
|
|
monkeypatch,
|
|
fallback_providers=[{"provider": "openrouter", "model": "upstage/solar-pro4"}],
|
|
)
|
|
text = _format_async(evt)
|
|
assert text.count("SUBAGENT MODEL REJECTED") == 1
|
|
assert "No fallback chain is configured" not in text
|