Files
hermes-agent/tests/tools/test_browser_npx_warmup.py
T
kshitijk4poor 547f985286 refactor(deadline): consolidate site-local tree-kills onto agent.deadline.kill_process_tree (#85125 4d)
Per-site decisions:

1. hermes_cli/_subprocess_compat.py kill_process_tree(proc) -> None:
   MIGRATED. Body now delegates to agent.deadline.kill_process_tree(proc.pid)
   via a function-local import; keeps the swallow-everything fail-open
   contract and the (proc) -> None signature (agent/shell_hooks.py imports
   it by name; _kill_git_process_tree alias preserved). The old body is kept
   verbatim as _legacy_kill_process_tree and used as fallback when the
   delegation import/call fails. A final proc.kill() is retained on the
   happy path so Popen bookkeeping sees the exit (matches old behavior).

2. tools/browser_tool.py _kill_process_tree(proc): MIGRATED, same pattern
   (delegate + _legacy_kill_process_tree fallback). Behavior delta: the old
   body sent SIGTERM then SIGKILL with zero grace between them; the shared
   primitive sends SIGKILL only. With no grace period the observable effect
   is identical, and the psutil descendant sweep now also reaches
   agent-browser's setsid'd daemon grandchild, which killpg alone missed.
   tests/tools/test_browser_npx_warmup.py's TestKillProcessTree repointed at
   the legacy fallback (its assertions describe the fallback's internals).

3. tools/code_execution_tool.py _kill_process_group(proc, escalate):
   MIGRATED. It was a plain parent+descendants terminate (then wait 5s +
   kill when escalate=True) — expressed as two delegated calls:
   kill_process_tree(pid, sig=SIGTERM), then on escalate-timeout
   kill_process_tree(pid, sig=SIGKILL). Delegation failure degrades to
   proc.kill(), mirroring the old psutil-failure fallback. Delta: the old
   body terminated children before the parent; the shared primitive
   signals the group atomically (child is a session leader via
   start_new_session=True) plus an identity-aware descendant sweep —
   strictly wider coverage, same signals.

4. gateway/status.py: KEPT BOTH SITES.
   - terminate_pid (~l305) taskkill wrapper: NOT migrated. Its contract is
     incompatible with the shared primitive — it must RAISE OSError with
     taskkill's stderr on non-zero exit (callers branch on that), falls back
     to os.kill on FileNotFoundError, and its POSIX branch is deliberately a
     single-PID SIGTERM/SIGKILL, not a tree kill. Wrapping the bool-returning
     fail-soft primitive would invert the error contract.
   - reap_gateway_children (~l2029): NOT migrated. It operates on a
     pre-snapshotted child list from a parent that is already dead
     (psutil.Process(pid) on the parent would fail), and every signal is
     wrapped in identity/ownership checks the primitive lacks: is_running()
     identity, zombie skip, and the skip-if-ppid-still-equals-parent guard,
     plus SIGTERM -> wait_procs -> SIGKILL staging and a reaped-count return.
     The coupling is the feature; migrating would delete the safety logic.

5. scripts/run_tests_parallel.py _kill_process_tree (~l253): NOT migrated.
   Dev tooling that intentionally kills by CAPTURED pgid because the direct
   child is usually already reaped (psutil/pid-based primitive cannot find
   it), and it avoids the psutil import on the test-runner hot path. Its
   docstring already documents why psutil is the wrong tool there.

New tests: tests/agent/test_treekill_consolidation.py — delegation +
raise-swallowing tests per migrated wrapper, consumer-identity checks, and
a live end-to-end probe (setsid grandchild dies through the compat wrapper,
zero survivors).
2026-08-25 01:34:56 +05:30

321 lines
14 KiB
Python

"""Tests for tools.browser_tool.warm_agent_browser_npx_cache (#43564, security
hardening follow-up on PR #44772 review).
warm_agent_browser_npx_cache() is the fire-and-forget helper `hermes update` /
`hermes doctor --fix` call to pre-fetch agent-browser via npx so the first real
browser-tool invocation in a session doesn't pay npx's registry-lookup cost.
It must never raise, must accurately report success/failure via its return
value, must use a credential-scrubbed and PATH-propagated environment (it
runs registry-fetched, potentially install-scripted npm code on every
`hermes update` — not only when a browser tool is actually used), must pass
--ignore-scripts (AGENT_BROWSER_NPX_SPEC is a floating ^0.26.0 range, not an
exact pin), and must kill the whole process tree — not just the top-level
npx PID — on timeout.
"""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
from tools.browser_tool import (
AGENT_BROWSER_NPX_SPEC,
_legacy_kill_process_tree,
warm_agent_browser_npx_cache,
)
def _mock_proc(returncode=0, communicate_side_effect=None, pid=4242):
proc = MagicMock()
proc.pid = pid
if communicate_side_effect is not None:
proc.communicate.side_effect = communicate_side_effect
else:
proc.communicate.return_value = ("", "")
proc.returncode = returncode
return proc
def test_returns_false_without_spawning_when_npx_unresolvable():
with patch("tools.browser_tool._resolve_npx_bin", return_value=None), patch(
"subprocess.Popen"
) as mock_popen:
assert warm_agent_browser_npx_cache() is False
mock_popen.assert_not_called()
def test_invokes_npx_with_ignore_scripts_prefer_offline_and_pinned_spec():
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), patch(
"subprocess.Popen", return_value=_mock_proc()
) as mock_popen:
assert warm_agent_browser_npx_cache() is True
mock_popen.assert_called_once()
args, _kwargs = mock_popen.call_args
assert args[0] == [
"/usr/bin/npx", "--ignore-scripts", "--prefer-offline", "-y",
AGENT_BROWSER_NPX_SPEC, "--version",
]
def test_stdin_is_explicitly_devnull_not_inherited():
"""Every subprocess call in tools/ must set stdin= explicitly
(scripts/check_subprocess_stdin.py) — in the TUI gateway, an inherited
stdin fd can be consumed by a child and cause the gateway's own
JSON-RPC stdin read to see a premature EOF (issue #14036). This call
has no reason to read from stdin at all, so it must be DEVNULL, not
merely "present in kwargs somewhere" (the checker is a literal-argument
textual scan, so stdin= folded into a shared kwargs dict wouldn't
satisfy it either — it must appear as a literal keyword on the call)."""
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), \
patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen:
warm_agent_browser_npx_cache()
_args, kwargs = mock_popen.call_args
assert kwargs.get("stdin") == subprocess.DEVNULL
def test_captures_stdout_and_stderr_instead_of_inheriting_parent_fds():
"""The npx registry fetch runs on every `hermes update` — its stdout/
stderr must not bleed into the caller's own output (and, on POSIX, an
inherited fd is one more handle a runaway grandchild could hold open)."""
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), \
patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen:
warm_agent_browser_npx_cache()
_args, kwargs = mock_popen.call_args
assert kwargs.get("stdout") == subprocess.PIPE
assert kwargs.get("stderr") == subprocess.PIPE
def test_uses_credential_scrubbed_environment():
"""Must not inherit the full parent environment — matching every other
agent-browser subprocess spawn (_build_browser_env), not the ambient
os.environ with every provider/gateway credential Hermes holds."""
scrubbed_env = {"PATH": "/scrubbed/bin", "SCRUBBED": "1"}
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), \
patch("tools.browser_tool._build_browser_env", return_value=dict(scrubbed_env)), \
patch("tools.browser_tool._merge_browser_path", side_effect=lambda p: p), \
patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen:
warm_agent_browser_npx_cache()
_args, kwargs = mock_popen.call_args
assert kwargs["env"]["SCRUBBED"] == "1"
assert "OPENAI_API_KEY" not in kwargs["env"]
def test_merges_extended_path_so_managed_only_npx_can_find_sibling_node():
"""If npx was resolved via the Hermes-managed/extended search (not the
ambient PATH), the child's own PATH must include that same directory —
npx's #!/usr/bin/env node shebang resolves `node` via the child's PATH
at exec time, not the resolving process's PATH."""
with patch("tools.browser_tool._resolve_npx_bin", return_value="/opt/hermes/node/bin/npx"), \
patch("tools.browser_tool._build_browser_env", return_value={"PATH": "/usr/bin"}), \
patch(
"tools.browser_tool._merge_browser_path",
return_value="/opt/hermes/node/bin:/usr/bin",
) as mock_merge, \
patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen:
warm_agent_browser_npx_cache()
mock_merge.assert_called_once_with("/usr/bin")
_args, kwargs = mock_popen.call_args
assert kwargs["env"]["PATH"] == "/opt/hermes/node/bin:/usr/bin"
def test_runs_in_its_own_process_group_on_posix(monkeypatch):
monkeypatch.setattr("os.name", "posix")
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), \
patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen:
warm_agent_browser_npx_cache()
_args, kwargs = mock_popen.call_args
assert kwargs.get("start_new_session") is True
def test_uses_new_process_group_creationflag_on_windows_instead_of_start_new_session():
"""start_new_session is a POSIX-only Popen kwarg (raises on Windows).
The Windows equivalent for _kill_process_tree's taskkill /T to have a
coherent tree to kill is CREATE_NEW_PROCESS_GROUP via creationflags."""
with patch("os.name", "nt"), \
patch("tools.browser_tool._resolve_npx_bin", return_value="C:\\npx.cmd"), \
patch("tools.browser_tool._build_browser_env", return_value={"PATH": "C:\\Windows"}), \
patch("tools.browser_tool._merge_browser_path", side_effect=lambda p: p), \
patch("subprocess.Popen", return_value=_mock_proc()) as mock_popen:
warm_agent_browser_npx_cache()
_args, kwargs = mock_popen.call_args
assert "start_new_session" not in kwargs
create_new_pgroup = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
assert kwargs["creationflags"] & create_new_pgroup == create_new_pgroup
def test_timeout_kills_the_whole_process_tree_not_just_the_pid():
"""subprocess.Popen.kill() only signals the direct child; npm/npx can
fork descendants that survive it and hold a capture pipe open past the
nominal timeout. On timeout, the whole process group/tree must be
killed, not just the top-level PID."""
proc = _mock_proc(
communicate_side_effect=[
subprocess.TimeoutExpired(cmd=["npx"], timeout=60.0), ("", ""),
]
)
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), \
patch("subprocess.Popen", return_value=proc), \
patch("tools.browser_tool._kill_process_tree") as mock_kill:
assert warm_agent_browser_npx_cache(timeout=60.0) is False
mock_kill.assert_called_once_with(proc)
assert proc.communicate.call_count == 2, (
"must attempt a second, bounded communicate() after the kill to reap "
"the now-dead process and drain its pipes, not just abandon it"
)
def test_timeout_cleanup_communicate_itself_raising_does_not_propagate():
"""The post-kill drain call is itself best-effort — if the process is
stuck badly enough that even the 5s cleanup communicate() times out (or
raises for any other reason), that must not escape and crash the
fire-and-forget caller (hermes_cli/doctor.py calls this bare)."""
proc = _mock_proc(
communicate_side_effect=[
subprocess.TimeoutExpired(cmd=["npx"], timeout=60.0),
subprocess.TimeoutExpired(cmd=["npx"], timeout=5),
]
)
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), \
patch("subprocess.Popen", return_value=proc), \
patch("tools.browser_tool._kill_process_tree") as mock_kill:
assert warm_agent_browser_npx_cache(timeout=60.0) is False
mock_kill.assert_called_once_with(proc)
def test_returns_false_on_nonzero_exit():
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), patch(
"subprocess.Popen", return_value=_mock_proc(returncode=1)
):
assert warm_agent_browser_npx_cache() is False
def test_returns_false_instead_of_raising_on_popen_failure():
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), patch(
"subprocess.Popen", side_effect=OSError("fork failed")
):
assert warm_agent_browser_npx_cache() is False
def test_returns_false_instead_of_raising_on_unexpected_communicate_exception():
"""Fire-and-forget contract: hermes_cli/doctor.py calls this bare (no
try/except of its own), so any exception must be swallowed here."""
proc = _mock_proc(communicate_side_effect=OSError("broken pipe"))
with patch("tools.browser_tool._resolve_npx_bin", return_value="/usr/bin/npx"), \
patch("subprocess.Popen", return_value=proc), \
patch("tools.browser_tool._kill_process_tree") as mock_kill:
assert warm_agent_browser_npx_cache() is False
mock_kill.assert_called_once_with(proc)
class TestLegacyKillProcessTree:
"""Contract of the pre-#85125 local fallback (used when agent.deadline
delegation fails); the delegating wrapper is covered in
tests/agent/test_treekill_consolidation.py."""
def test_posix_kills_process_group_term_then_kill(self, monkeypatch):
import signal
proc = MagicMock()
proc.pid = 999
monkeypatch.setattr("os.name", "posix")
monkeypatch.setattr("os.getpgid", lambda pid: 999)
killpg_calls = []
monkeypatch.setattr(
"os.killpg", lambda pgid, sig: killpg_calls.append((pgid, sig))
)
_legacy_kill_process_tree(proc)
assert killpg_calls == [(999, signal.SIGTERM), (999, signal.SIGKILL)]
def test_posix_missing_process_returns_silently(self, monkeypatch):
proc = MagicMock()
proc.pid = 999
monkeypatch.setattr("os.name", "posix")
def _raise(pid):
raise ProcessLookupError()
monkeypatch.setattr("os.getpgid", _raise)
_legacy_kill_process_tree(proc) # must not raise
def test_posix_missing_killpg_attribute_falls_back_to_proc_kill(self, monkeypatch):
"""Some POSIX-like environments may lack os.killpg entirely (the
implementation resolves it defensively via
``getattr(os, "killpg", None)`` — flagged by
scripts/check-windows-footguns.py against a bare ``os.killpg``
reference). When that resolution comes back None, the fallback must
be a plain ``proc.kill()`` of just the top-level PID, not an
AttributeError."""
import os as os_module
proc = MagicMock()
proc.pid = 999
monkeypatch.setattr("os.name", "posix")
monkeypatch.delattr(os_module, "killpg", raising=False)
_legacy_kill_process_tree(proc)
proc.kill.assert_called_once()
def test_posix_missing_killpg_fallback_proc_kill_failure_does_not_raise(self, monkeypatch):
import os as os_module
proc = MagicMock()
proc.pid = 999
proc.kill.side_effect = OSError("already reaped")
monkeypatch.setattr("os.name", "posix")
monkeypatch.delattr(os_module, "killpg", raising=False)
_legacy_kill_process_tree(proc) # must not raise
def test_posix_sigterm_permission_denied_does_not_attempt_sigkill(self, monkeypatch):
"""If SIGTERM itself is rejected (e.g. a stale pgid reused by an
unrelated, unkillable process), the loop must bail out rather than
plow ahead into a second signal against the wrong target."""
import signal
proc = MagicMock()
proc.pid = 999
monkeypatch.setattr("os.name", "posix")
monkeypatch.setattr("os.getpgid", lambda pid: 999)
killpg_calls = []
def fake_killpg(pgid, sig):
killpg_calls.append((pgid, sig))
raise PermissionError()
monkeypatch.setattr("os.killpg", fake_killpg)
_legacy_kill_process_tree(proc) # must not raise
assert killpg_calls == [(999, signal.SIGTERM)]
def test_windows_uses_taskkill_with_tree_and_force_flags(self, monkeypatch):
proc = MagicMock()
proc.pid = 4321
monkeypatch.setattr("os.name", "nt")
with patch("subprocess.run") as mock_run:
_legacy_kill_process_tree(proc)
mock_run.assert_called_once()
cmd = mock_run.call_args.args[0]
assert cmd == ["taskkill", "/PID", "4321", "/T", "/F"]
def test_windows_taskkill_failure_does_not_raise(self, monkeypatch):
proc = MagicMock()
proc.pid = 4321
monkeypatch.setattr("os.name", "nt")
with patch("subprocess.run", side_effect=OSError("taskkill missing")):
_legacy_kill_process_tree(proc) # must not raise