Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-30 13:19:27 -05:00
157 changed files with 10059 additions and 831 deletions
@@ -189,6 +189,32 @@ class TestBrowserEvalSupervisorPath:
json.loads(bt._browser_eval("1+1"))
assert called["subprocess"] is True
def test_subprocess_reference_chain_error_becomes_guidance(self, monkeypatch):
"""The CLI subprocess can't retry with returnByValue=False, so the
cryptic 'Object reference chain is too long' CDP error must be turned
into actionable guidance instead of surfaced raw."""
import tools.browser_tool as bt
# No supervisor → subprocess path runs.
_patch_supervisor(monkeypatch, None)
def _fake_subprocess(task_id, cmd, args):
assert cmd == "eval"
return {
"success": False,
"error": "Runtime.evaluate failed: Object reference chain is too long",
}
monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess)
out = json.loads(bt._browser_eval("document.body"))
assert out["success"] is False
# Raw protocol error must NOT leak through.
assert "reference chain" not in out["error"].lower()
# Actionable guidance instead.
assert "primitive" in out["error"].lower()
assert "DOM node" in out["error"] or "dom node" in out["error"].lower()
# ---------------------------------------------------------------------------
# Response shaping: CDPSupervisor.evaluate_runtime
@@ -361,3 +387,91 @@ class TestEvaluateRuntimeResponseShaping:
finally:
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
def _make_supervisor_with_cdp_fn(cdp_fn):
"""Like ``_make_supervisor_with_cdp`` but lets the test supply a coroutine
function as ``_cdp`` so behaviour can vary by params (e.g. returnByValue).
"""
import asyncio
import threading
from tools.browser_supervisor import CDPSupervisor
sup = object.__new__(CDPSupervisor)
sup._state_lock = threading.Lock()
sup._active = True
sup._page_session_id = "test-session-id"
loop = asyncio.new_event_loop()
def _runner():
asyncio.set_event_loop(loop)
loop.run_forever()
thread = threading.Thread(target=_runner, daemon=True)
thread.start()
sup._cdp = cdp_fn # type: ignore[method-assign]
sup._loop = loop
sup._thread = thread
return sup
class TestEvaluateRuntimeDomNodeCrashRetry:
"""returnByValue=True on a DOM node fails CDP serialization with 'Object
reference chain is too long'. evaluate_runtime must retry with
returnByValue=False and return the node's description instead of crashing.
"""
def test_reference_chain_crash_retries_without_by_value(self):
calls = []
async def _fake_cdp(method, params=None, *, session_id=None, timeout=10.0):
by_value = (params or {}).get("returnByValue")
calls.append(by_value)
if by_value:
# Mirror _read_loop turning a top-level CDP error into a RuntimeError.
raise RuntimeError(
"CDP error on id=7: {'code': -32000, "
"'message': 'Object reference chain is too long'}"
)
# returnByValue=False: Chrome returns the node's description, no value.
return {
"id": 8,
"result": {
"result": {
"type": "object",
"subtype": "node",
"description": "body",
}
},
}
sup = _make_supervisor_with_cdp_fn(_fake_cdp)
try:
out = sup.evaluate_runtime("document.body")
assert out["ok"] is True
assert out["result"] == "body"
assert out["result_type"] == "object"
# First call by_value=True (crashed), retried with by_value=False.
assert calls == [True, False]
finally:
_stop_supervisor(sup)
def test_unrelated_error_does_not_retry(self):
calls = []
async def _fake_cdp(method, params=None, *, session_id=None, timeout=10.0):
calls.append((params or {}).get("returnByValue"))
raise RuntimeError("CDP error on id=3: {'message': 'Target closed'}")
sup = _make_supervisor_with_cdp_fn(_fake_cdp)
try:
out = sup.evaluate_runtime("document.body")
assert out["ok"] is False
assert "Target closed" in out["error"]
# No retry for unrelated failures — exactly one call.
assert calls == [True]
finally:
_stop_supervisor(sup)
+23 -14
View File
@@ -345,15 +345,23 @@ class TestShellFileOpsHelpers:
def test_add_line_numbers(self, file_ops):
content = "line one\nline two\nline three"
result = file_ops._add_line_numbers(content)
assert " 1|line one" in result
assert " 2|line two" in result
assert " 3|line three" in result
# Compact gutter: "<n>|content" (no fixed-width padding).
assert "1|line one" in result
assert "2|line two" in result
assert "3|line three" in result
def test_add_line_numbers_with_offset(self, file_ops):
content = "continued\nmore"
result = file_ops._add_line_numbers(content, start_line=50)
assert " 50|continued" in result
assert " 51|more" in result
assert "50|continued" in result
assert "51|more" in result
def test_add_line_numbers_padded_env_override(self, file_ops, monkeypatch):
# Legacy fixed-width format available via HERMES_READ_GUTTER=padded.
monkeypatch.setenv("HERMES_READ_GUTTER", "padded")
result = file_ops._add_line_numbers("line one\nline two")
assert " 1|line one" in result
assert " 2|line two" in result
def test_add_line_numbers_truncates_long_lines(self, file_ops):
long_line = "x" * (MAX_LINE_LENGTH + 100)
@@ -405,7 +413,7 @@ class TestShellFileOpsHelpers:
assert "HERMES_FENCE" not in result.content
assert "\x1b]" not in result.content
assert "\x07" not in result.content
assert " 1|print('ok')" in result.content
assert "1|print('ok')" in result.content
def test_read_file_raw_strips_leaked_terminal_fence_markers(self, mock_env):
leaked = (
@@ -638,12 +646,14 @@ class TestPatchReplacePostWriteVerification:
state = {"content": "hello world\n"}
def side_effect(command, stdin_data=None, **kwargs):
# Write is `cat > path` — detect by the `>` redirect, NOT just `cat `
if command.startswith("cat >"):
if stdin_data is not None:
state["content"] = stdin_data
# A write is the only call that pipes content over stdin — key
# on that behavioral signal rather than the exact write command,
# which is an atomic temp-file + mv script (`set -e; ... mv ...`),
# not a bare `cat > path`.
if stdin_data is not None:
state["content"] = stdin_data
return {"output": "", "returncode": 0}
if command.startswith("cat "): # read
if command.startswith("cat "): # read / verify
return {"output": state["content"], "returncode": 0}
if command.startswith("mkdir "):
return {"output": "", "returncode": 0}
@@ -664,9 +674,8 @@ class TestPatchReplacePostWriteVerification:
state = {"content": "hello world\n"}
def side_effect(command, stdin_data=None, **kwargs):
if command.startswith("cat >"): # write
if stdin_data is not None:
state["content"] = stdin_data
if stdin_data is not None: # write (atomic temp-file + mv script)
state["content"] = stdin_data
return {"output": "", "returncode": 0}
if command.startswith("cat "): # read
call_count["cat"] += 1
@@ -292,7 +292,7 @@ class TestPaginationBounds:
result = ops.read_file("notes.txt", offset=0, limit=0)
assert result.error is None
assert " 1|line1" in result.content
assert "1|line1" in result.content
sed_commands = [cmd for cmd in commands if cmd.startswith("sed -n")]
assert sed_commands == ["sed -n '1,1p' 'notes.txt'"]
@@ -0,0 +1,197 @@
"""Regression tests for file-tool path resolution base correctness.
The bug (observed in a worktree dev session, May 2026): when the resolution
base for a relative path is itself RELATIVE — e.g. ``TERMINAL_CWD="."`` from a
stale config — ``_resolve_path_for_task`` resolved the path against the agent's
PROCESS cwd instead of the intended workspace. In a git-worktree session this
silently routed ``patch``/``write_file`` edits into the *main* checkout: the
write landed, self-verified, and reported success — against the wrong file.
The agent then grepped the worktree, saw nothing, and concluded the patch tool
had silently no-op'd. It hadn't; it wrote to the wrong place.
Core invariant these tests pin:
The resolution base for a relative path MUST always be absolute. A relative
``TERMINAL_CWD`` (``.``, ``./sub``, ``..``) must be anchored deterministically,
never left to resolve against whatever the process cwd happens to be.
"""
import os
from pathlib import Path
import pytest
import tools.file_tools as ft
@pytest.fixture
def _isolated_cwd(tmp_path, monkeypatch):
"""Two checkouts: workspace (intended) + decoy (process cwd)."""
workspace = tmp_path / "workspace"
decoy = tmp_path / "decoy"
workspace.mkdir()
decoy.mkdir()
(workspace / "target.py").write_text("WORKSPACE_ORIGINAL\n")
(decoy / "target.py").write_text("DECOY_ORIGINAL\n")
# Process cwd = decoy, analogous to "main repo" while the terminal is in
# the worktree.
monkeypatch.chdir(decoy)
# No live-terminal-cwd tracking recorded yet (fresh-session condition).
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
return workspace, decoy
def test_relative_terminal_cwd_anchors_to_absolute_not_process_cwd(_isolated_cwd, monkeypatch):
"""TERMINAL_CWD='.' must NOT silently mean 'the agent process cwd'.
A relative base is meaningless as a resolution anchor. The resolver must
make it absolute deterministically. We assert the resolved path is
absolute and stable regardless of where os.getcwd() points.
"""
workspace, decoy = _isolated_cwd
# Poison config: literal relative '.'
monkeypatch.setenv("TERMINAL_CWD", ".")
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute(), f"resolution base leaked a relative path: {resolved}"
# The exact anchor for a bare '.' is the process cwd resolved to absolute —
# that is acceptable as long as it is ABSOLUTE and stable. The bug was that
# a relative base produced surprising results; the fix is that the base is
# always absolutised. (We do not require it to point at the workspace here —
# that's what live-cwd tracking is for; see the next test.)
assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve())
def test_live_tracking_cwd_wins_over_relative_terminal_cwd(_isolated_cwd, monkeypatch):
"""When the terminal reports its absolute cwd, that is authoritative.
This is the real-world fix: the terminal's tracked absolute cwd (the
worktree) must override a stale relative TERMINAL_CWD so edits land where
the agent is actually working.
"""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", ".")
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
def test_absolute_terminal_cwd_used_verbatim(_isolated_cwd, monkeypatch):
"""An absolute TERMINAL_CWD is the resolution base (no live tracking)."""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
def test_absolute_input_path_ignores_base(_isolated_cwd, monkeypatch):
"""An absolute input path is never re-anchored."""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", ".")
abs_target = str(workspace / "target.py")
resolved = ft._resolve_path_for_task(abs_target, task_id="default")
assert resolved == Path(abs_target).resolve()
def test_resolution_base_always_absolute_no_terminal_cwd(_isolated_cwd, monkeypatch):
"""With TERMINAL_CWD unset, the base falls back to an ABSOLUTE process cwd."""
workspace, decoy = _isolated_cwd
monkeypatch.delenv("TERMINAL_CWD", raising=False)
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute()
assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve())
# ── B-(ii): workspace-divergence warning ────────────────────────────────────
def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monkeypatch):
"""Relative path resolving outside the live workspace must warn."""
workspace, decoy = _isolated_cwd
# Live cwd = workspace, but the relative path resolves to decoy (process cwd)
# because TERMINAL_CWD is the poison '.'. Simulate by pointing live tracking
# at workspace while the resolved path is under decoy.
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved_in_decoy = decoy / "target.py"
warn = ft._path_resolution_warning("target.py", resolved_in_decoy, task_id="default")
assert warn is not None
assert "OUTSIDE the active workspace" in warn
assert str(decoy) in warn
assert str(workspace) in warn
def test_no_warning_when_relative_path_inside_workspace(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved_in_workspace = workspace / "target.py"
warn = ft._path_resolution_warning("target.py", resolved_in_workspace, task_id="default")
assert warn is None
def test_no_warning_for_absolute_input(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
warn = ft._path_resolution_warning(str(decoy / "target.py"), decoy / "target.py", task_id="default")
assert warn is None
def test_no_warning_when_no_live_cwd(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
warn = ft._path_resolution_warning("target.py", decoy / "target.py", task_id="default")
assert warn is None
# ── Fix A: write_file / patch report the resolved ABSOLUTE path ──────────────
def test_write_file_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
"""write_file_tool must put the absolute on-disk path in files_modified."""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
import json
out = json.loads(ft.write_file_tool("newfile.txt", "hello\n", task_id="t1"))
expected = str((workspace / "newfile.txt").resolve())
assert out.get("resolved_path") == expected
assert out.get("files_modified") == [expected]
assert (workspace / "newfile.txt").read_text() == "hello\n"
def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
"""patch_tool (replace mode) must put the absolute on-disk path in files_modified."""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
import json
out = json.loads(ft.patch_tool(
mode="replace", path="target.py",
old_string="WORKSPACE_ORIGINAL", new_string="WORKSPACE_PATCHED",
task_id="t1",
))
expected = str((workspace / "target.py").resolve())
assert not out.get("error"), out
assert out.get("resolved_path") == expected
assert out.get("files_modified") == [expected]
assert "WORKSPACE_PATCHED" in (workspace / "target.py").read_text()
# And the decoy copy is untouched.
assert (decoy / "target.py").read_text() == "DECOY_ORIGINAL\n"
+172
View File
@@ -107,5 +107,177 @@ class TestCheckSensitivePathMacOSBypass:
assert _check_sensitive_path("/tmp/safe_file.txt") is None
class TestAtomicWrite:
"""write_file / patch land via a temp-file + atomic rename.
The invariant: a write that fails partway NEVER corrupts the existing
file, and the swap is a real rename (so a reader either sees the full
old content or the full new content, never a half-written file). These
run against a real LocalEnvironment so the actual shell script executes.
"""
@pytest.fixture
def ops(self, tmp_path: Path):
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
env = LocalEnvironment(cwd=str(tmp_path))
return ShellFileOperations(env, cwd=str(tmp_path))
def test_overwrite_changes_inode(self, ops, tmp_path: Path):
# A real rename allocates a new inode for the target; an in-place
# rewrite would keep the same inode. This proves the swap is atomic.
target = tmp_path / "f.txt"
target.write_text("v1")
ino_before = os.stat(target).st_ino
res = ops.write_file(str(target), "v2 content")
assert res.error is None, res.error
assert target.read_text() == "v2 content"
assert os.stat(target).st_ino != ino_before
def test_overwrite_preserves_mode(self, ops, tmp_path: Path):
target = tmp_path / "perms.txt"
target.write_text("old")
os.chmod(target, 0o640)
res = ops.write_file(str(target), "new")
assert res.error is None, res.error
assert (os.stat(target).st_mode & 0o777) == 0o640
def test_failed_write_leaves_original_intact(self, ops, tmp_path: Path):
# A read-only parent directory means the temp file can't be created,
# so the write fails BEFORE any rename. The original must survive
# byte-for-byte and no temp file may be left behind.
if hasattr(os, "geteuid") and os.geteuid() == 0:
pytest.skip("root bypasses directory permission bits")
locked = tmp_path / "locked"
locked.mkdir()
target = locked / "f.txt"
target.write_text("ORIGINAL\n")
os.chmod(locked, 0o500) # r-x: cannot create entries inside
try:
res = ops.write_file(str(target), "SHOULD NOT LAND")
finally:
os.chmod(locked, 0o700) # restore for cleanup
assert res.error is not None
assert target.read_text() == "ORIGINAL\n"
assert [p for p in os.listdir(locked) if ".hermes-tmp" in p] == []
def test_no_temp_file_leaked_on_success(self, ops, tmp_path: Path):
target = tmp_path / "f.txt"
ops.write_file(str(target), "hello\n")
assert [p for p in os.listdir(tmp_path) if ".hermes-tmp" in p] == []
def test_special_chars_roundtrip(self, ops, tmp_path: Path):
target = tmp_path / "special.txt"
tricky = "q 'single' \"double\" $VAR `cmd` \\back\nünïcödé 日本語\n"
res = ops.write_file(str(target), tricky)
assert res.error is None, res.error
assert target.read_text(encoding="utf-8") == tricky
def test_patch_routes_through_atomic_write(self, ops, tmp_path: Path):
target = tmp_path / "edit.py"
target.write_text("a = 1\nb = 2\nc = 3\n")
os.chmod(target, 0o600)
res = ops.patch_replace(str(target), "b = 2", "b = 22")
assert res.success, res.error
assert target.read_text() == "a = 1\nb = 22\nc = 3\n"
assert (os.stat(target).st_mode & 0o777) == 0o600
class TestBomHandling:
"""UTF-8 BOM is stripped on read and preserved across write/patch.
A BOM (U+FEFF, bytes EF BB BF) is an invisible leading marker some
Windows editors prepend. The agent should never see it in read output,
but a file that had one on disk must keep it after an edit so the byte
signature is preserved.
"""
BOM = "\ufeff"
@pytest.fixture
def ops(self, tmp_path: Path):
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
env = LocalEnvironment(cwd=str(tmp_path))
return ShellFileOperations(env, cwd=str(tmp_path))
def test_helpers(self):
from tools.file_operations import _strip_bom, _has_bom
assert _strip_bom("\ufeffhello") == ("hello", True)
assert _strip_bom("hello") == ("hello", False)
assert _strip_bom("") == ("", False)
# mid-string BOM is data, not a marker — left alone
assert _strip_bom("a\ufeffb") == ("a\ufeffb", False)
assert _has_bom("\ufeffx") is True
assert _has_bom("x") is False
assert _has_bom(None) is False
def test_read_strips_bom(self, ops, tmp_path: Path):
target = tmp_path / "bom.py"
# Write raw bytes with a real UTF-8 BOM prefix.
target.write_bytes(self.BOM.encode("utf-8") + b"import os\nx = 1\n")
res = ops.read_file(str(target))
assert res.error is None, res.error
# Line 1 content must NOT carry the phantom U+FEFF.
first_line = res.content.split("\n", 1)[0]
assert self.BOM not in first_line
assert first_line.endswith("import os")
def test_read_raw_strips_bom(self, ops, tmp_path: Path):
target = tmp_path / "bom.txt"
target.write_bytes(self.BOM.encode("utf-8") + b"hello\nworld\n")
res = ops.read_file_raw(str(target))
assert res.error is None, res.error
assert not res.content.startswith(self.BOM)
assert res.content == "hello\nworld\n"
def test_write_preserves_bom(self, ops, tmp_path: Path):
# Existing file has a BOM; agent rewrites with BOM-less content.
target = tmp_path / "config.txt"
target.write_bytes(self.BOM.encode("utf-8") + b"old\n")
res = ops.write_file(str(target), "new content\n")
assert res.error is None, res.error
raw = target.read_bytes()
assert raw.startswith(self.BOM.encode("utf-8")) # BOM restored
assert raw == self.BOM.encode("utf-8") + b"new content\n"
def test_write_no_bom_when_original_had_none(self, ops, tmp_path: Path):
target = tmp_path / "plain.txt"
target.write_text("old\n")
res = ops.write_file(str(target), "new\n")
assert res.error is None, res.error
assert not target.read_bytes().startswith(self.BOM.encode("utf-8"))
def test_write_does_not_double_bom(self, ops, tmp_path: Path):
# If content already carries a BOM and the file had one, don't add a
# second.
target = tmp_path / "config.txt"
target.write_bytes(self.BOM.encode("utf-8") + b"old\n")
res = ops.write_file(str(target), self.BOM + "new\n")
assert res.error is None, res.error
raw = target.read_bytes()
# exactly one BOM
assert raw == self.BOM.encode("utf-8") + b"new\n"
def test_patch_roundtrip_preserves_bom(self, ops, tmp_path: Path):
target = tmp_path / "edit.py"
target.write_bytes(self.BOM.encode("utf-8") + b"a = 1\nb = 2\nc = 3\n")
res = ops.patch_replace(str(target), "b = 2", "b = 22")
assert res.success, res.error
raw = target.read_bytes()
assert raw.startswith(self.BOM.encode("utf-8")) # marker survived
assert raw == self.BOM.encode("utf-8") + b"a = 1\nb = 22\nc = 3\n"
def test_patch_matches_first_line_through_bom(self, ops, tmp_path: Path):
# The whole point: an edit targeting the BOM-prefixed first line
# must match cleanly (the matcher sees BOM-stripped content).
target = tmp_path / "mod.py"
target.write_bytes(self.BOM.encode("utf-8") + b"import os\nimport sys\n")
res = ops.patch_replace(str(target), "import os", "import os, json")
assert res.success, res.error
raw = target.read_bytes()
assert raw == self.BOM.encode("utf-8") + b"import os, json\nimport sys\n"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+77
View File
@@ -203,6 +203,83 @@ class TestSIGKILLEscalation:
assert "interrupted" in result_holder["value"]["output"].lower()
# ---------------------------------------------------------------------------
# Regression: _run_tool cleanup on BaseException (issue #35309)
# ---------------------------------------------------------------------------
class TestRunToolCleanupOnBaseException:
"""Verify that _run_tool cleans up _interrupted_threads even when
_invoke_tool raises a BaseException (e.g. CancelledError).
Regression test for #35309: without the finally block, a BaseException
bypasses ``except Exception``, leaking the worker tid into
_interrupted_threads. ThreadPoolExecutor recycles tids, so the next
tool scheduled on the same thread is instantly "interrupted".
"""
def test_cleanup_on_base_exception(self):
from unittest.mock import MagicMock, patch
import types
from tools.interrupt import set_interrupt, is_interrupted, _interrupted_threads, _lock
# Clear global state
with _lock:
_interrupted_threads.clear()
# Build a minimal mock agent with the attributes _run_tool needs
agent = MagicMock()
agent._interrupt_requested = False
agent._tool_worker_threads = set()
agent._tool_worker_threads_lock = threading.Lock()
# _set_interrupt delegates to the real module
def _mock_set_interrupt(active, tid=None):
set_interrupt(active, tid)
agent._set_interrupt = _mock_set_interrupt
# _invoke_tool raises BaseException (simulating CancelledError)
agent._invoke_tool = MagicMock(side_effect=BaseException("simulated CancelledError"))
# Bind the real concurrent method so we get _run_tool
from run_agent import AIAgent
agent._execute_tool_calls_concurrent = types.MethodType(
AIAgent._execute_tool_calls_concurrent, agent
)
# Build a single tool call
tc = MagicMock()
tc.id = "tc_base_exc"
tc.function.name = "dummy_tool"
tc.function.arguments = "{}"
assistant_msg = MagicMock()
assistant_msg.tool_calls = [tc]
# _execute_tool_calls_concurrent will submit _run_tool to a
# ThreadPoolExecutor. The BaseException propagates out of the
# worker, but the finally block should still clean up.
try:
agent._execute_tool_calls_concurrent(assistant_msg, [], "default")
except Exception:
pass # ThreadPoolExecutor may re-raise
# After the worker finishes (even with BaseException), the worker
# tid should have been removed from _interrupted_threads and
# _tool_worker_threads.
assert len(agent._tool_worker_threads) == 0, (
f"_tool_worker_threads not cleaned up: {agent._tool_worker_threads}"
)
# Verify no stale tid is left in the global interrupt set. The
# worker thread is recycled by ThreadPoolExecutor, so a leaked tid
# would poison the next task on that thread. We cleared the set at
# the start and never set any interrupt ourselves, so a leak from
# _run_tool is the only way an entry could land here.
with _lock:
leaked = set(_interrupted_threads)
assert leaked == set(), f"leaked tids in _interrupted_threads: {leaked}"
# ---------------------------------------------------------------------------
# Manual smoke test checklist (not automated)
# ---------------------------------------------------------------------------
@@ -234,6 +234,44 @@ def test_browserbase_does_not_use_gateway_only_configuration():
assert provider.is_available() is False
def test_browser_use_availability_skips_refresh_for_expired_cached_gateway_token(tmp_path, monkeypatch):
_install_fake_tools_package()
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
expired_at = "2000-01-01T00:00:00+00:00"
(tmp_path / "auth.json").write_text(
'{"providers":{"nous":{"access_token":"expired-token","refresh_token":"refresh-token","expires_at":"%s"}}}'
% expired_at,
encoding="utf-8",
)
refresh_calls = []
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
refresh_calls.append(refresh_skew_seconds)
return "fresh-token"
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
_record_refresh,
)
env = os.environ.copy()
env.pop("BROWSER_USE_API_KEY", None)
env.update({
"HERMES_HOME": str(tmp_path),
"BROWSER_USE_GATEWAY_URL": "http://127.0.0.1:3009",
})
with patch.dict(os.environ, env, clear=True):
browser_use_module = _load_plugin_module(
"plugins.browser.browser_use.provider",
"browser/browser_use/provider.py",
)
provider = browser_use_module.BrowserUseBrowserProvider()
assert provider.is_available() is True
assert refresh_calls == []
def test_browser_use_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
_install_fake_tools_package()
env = os.environ.copy()
+35
View File
@@ -12,6 +12,7 @@ assert MODULE_SPEC and MODULE_SPEC.loader
managed_tool_gateway = module_from_spec(MODULE_SPEC)
sys.modules[MODULE_SPEC.name] = managed_tool_gateway
MODULE_SPEC.loader.exec_module(managed_tool_gateway)
is_managed_tool_gateway_ready = managed_tool_gateway.is_managed_tool_gateway_ready
resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
@@ -97,3 +98,37 @@ def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkey
)
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"
def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expired_at = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat()
(tmp_path / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "expired-token",
"refresh_token": "refresh-token",
"expires_at": expired_at,
}
}
}))
refresh_calls = []
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
refresh_calls.append(refresh_skew_seconds)
return "fresh-token"
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
_record_refresh,
)
with patch.dict(
os.environ,
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com"},
clear=False,
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
assert is_managed_tool_gateway_ready("modal") is True
assert refresh_calls == []
+218
View File
@@ -1,9 +1,12 @@
"""Tests for MCP stability fixes — event loop handler, PID tracking, shutdown robustness."""
import asyncio
import os
import signal
from unittest.mock import patch, MagicMock
import pytest
# ---------------------------------------------------------------------------
@@ -171,6 +174,221 @@ class TestStdioPidTracking:
assert fake_pid not in _orphan_stdio_pids
# ---------------------------------------------------------------------------
# Fix 2b: stdio descendant reaping via process group (issue #23799)
# ---------------------------------------------------------------------------
#
# When a stdio MCP wrapper (e.g. ``openclaw mcp serve``) itself spawns a
# helper subprocess (``claude mcp serve``) and then exits, the helper
# reparents to systemd-user and is invisible to the per-pid orphan reaper.
# The fix captures the wrapper's pgid at spawn time and reaps via killpg,
# which reaches same-group descendants whether or not the direct pid is alive.
class TestStdioPgroupReaping:
"""_kill_orphaned_mcp_children reaps via killpg when a pgid is tracked."""
def _reset_state(self):
from tools.mcp_tool import _stdio_pids, _orphan_stdio_pids, _stdio_pgids, _lock
with _lock:
_stdio_pids.clear()
_orphan_stdio_pids.clear()
_stdio_pgids.clear()
def test_killpg_used_when_pgid_tracked(self, monkeypatch):
"""SIGTERM and SIGKILL route through killpg when pgid is known."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 525252
fake_pgid = 525252 # session leader: pgid == pid
with _lock:
_orphan_stdio_pids.add(fake_pid)
_stdio_pgids[fake_pid] = fake_pgid
fake_sigkill = 9
monkeypatch.setattr(signal, "SIGKILL", fake_sigkill, raising=False)
# Ensure os.killpg exists on this platform for the test to make sense;
# the production fallback path is covered by the per-pid tests above.
if not hasattr(os, "killpg"):
pytest.skip("os.killpg not available on this platform")
with patch("tools.mcp_tool.os.killpg") as mock_killpg, \
patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=True), \
patch("time.sleep"):
_kill_orphaned_mcp_children()
# Both phases should have used killpg (pgroup reach), not per-pid kill.
mock_killpg.assert_any_call(fake_pgid, signal.SIGTERM)
mock_killpg.assert_any_call(fake_pgid, fake_sigkill)
assert mock_killpg.call_count == 2
mock_kill.assert_not_called()
with _lock:
assert fake_pid not in _orphan_stdio_pids
assert fake_pid not in _stdio_pgids
def test_killpg_failure_falls_back_to_kill(self, monkeypatch):
"""If killpg raises ProcessLookupError (pgroup gone), try os.kill."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 636363
fake_pgid = 636363
with _lock:
_orphan_stdio_pids.add(fake_pid)
_stdio_pgids[fake_pid] = fake_pgid
if not hasattr(os, "killpg"):
pytest.skip("os.killpg not available on this platform")
with patch(
"tools.mcp_tool.os.killpg",
side_effect=ProcessLookupError("no such process group"),
) as mock_killpg, \
patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=False), \
patch("time.sleep"):
_kill_orphaned_mcp_children()
# killpg was attempted (phase 1 SIGTERM) and fell back to os.kill.
# Phase 3 skips because _pid_exists returns False (direct pid gone).
mock_killpg.assert_called()
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
with _lock:
assert fake_pid not in _orphan_stdio_pids
assert fake_pid not in _stdio_pgids
def test_no_pgid_uses_per_pid_kill(self, monkeypatch):
"""When no pgid is recorded (e.g. Windows), fall back to os.kill."""
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_lock,
)
self._reset_state()
fake_pid = 747474
with _lock:
_orphan_stdio_pids.add(fake_pid)
# No entry in _stdio_pgids.
with patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=False), \
patch("time.sleep"):
# killpg may or may not exist; either way the no-pgid path skips it.
_kill_orphaned_mcp_children()
mock_kill.assert_any_call(fake_pid, signal.SIGTERM)
with _lock:
assert fake_pid not in _orphan_stdio_pids
@pytest.mark.live_system_guard_bypass
@pytest.mark.skipif(
not hasattr(os, "killpg") or not hasattr(os, "setsid"),
reason="POSIX-only: requires os.killpg and os.setsid",
)
def test_grandchild_reaped_via_pgroup(self, tmp_path):
"""End-to-end: parent spawns grandchild, parent exits, killpg reaps grandchild.
Mirrors issue #23799: a stdio MCP wrapper (parent) launches a long-lived
helper subprocess (grandchild) in the same process group, then the
wrapper exits while the grandchild keeps running. killpg on the pgid
captured at spawn time must still deliver the signal to the grandchild.
Marked ``live_system_guard_bypass`` because this test genuinely needs
real signal delivery to its own subprocess tree (the conftest guard
only knows the test's *initial* children; the spawned tree here is
outside that allowlist).
"""
import subprocess
import sys
import time as _time
psutil = pytest.importorskip("psutil")
# Grandchild: sleep forever, write its pid then wait.
grandchild_pid_file = tmp_path / "grandchild.pid"
grandchild_script = tmp_path / "grandchild.py"
grandchild_script.write_text(
"import os, sys, time\n"
f"open({str(grandchild_pid_file)!r}, 'w').write(str(os.getpid()))\n"
"while True:\n"
" time.sleep(0.5)\n"
)
# Parent: spawn grandchild, exit immediately (without killing it).
parent_script = tmp_path / "parent.py"
parent_script.write_text(
"import subprocess, sys\n"
f"subprocess.Popen([sys.executable, {str(grandchild_script)!r}])\n"
# Parent exits — grandchild reparents to init.
)
# Spawn parent in its own session (mirrors stdio_client behaviour).
parent = subprocess.Popen(
[sys.executable, str(parent_script)],
start_new_session=True,
)
parent_pgid = os.getpgid(parent.pid)
# Wait for parent to exit and grandchild to spin up.
parent.wait(timeout=5)
deadline = _time.time() + 5
while _time.time() < deadline and not grandchild_pid_file.exists():
_time.sleep(0.05)
assert grandchild_pid_file.exists(), "grandchild did not start"
grandchild_pid = int(grandchild_pid_file.read_text().strip())
# Sanity: grandchild is alive and shares the parent's pgid.
assert psutil.pid_exists(grandchild_pid)
assert os.getpgid(grandchild_pid) == parent_pgid
# Drive the reaper: register the parent pid + pgid as an orphan.
from tools.mcp_tool import (
_kill_orphaned_mcp_children,
_orphan_stdio_pids,
_stdio_pgids,
_stdio_pids,
_lock,
)
with _lock:
_stdio_pids.clear()
_orphan_stdio_pids.clear()
_stdio_pgids.clear()
_orphan_stdio_pids.add(parent.pid)
_stdio_pgids[parent.pid] = parent_pgid
try:
_kill_orphaned_mcp_children()
finally:
# Belt-and-suspenders: ensure grandchild is dead even if test fails.
try:
os.kill(grandchild_pid, signal.SIGKILL)
except ProcessLookupError:
pass
# Grandchild should be gone — SIGTERM via killpg in phase 1 reached it.
deadline = _time.time() + 3
while _time.time() < deadline and psutil.pid_exists(grandchild_pid):
_time.sleep(0.05)
assert not psutil.pid_exists(grandchild_pid), (
"grandchild survived killpg-based reaping (issue #23799 regression)"
)
# ---------------------------------------------------------------------------
# Fix 3: MCP reload timeout (cli.py)
# ---------------------------------------------------------------------------
+84
View File
@@ -1220,6 +1220,90 @@ class TestParseTargetRefSlack:
assert _parse_target_ref("telegram", "C0B0QV5434G")[2] is False
class TestParseTargetRefEmail:
"""_parse_target_ref recognizes email addresses as explicit for the email platform."""
def test_standard_email_is_explicit(self):
chat_id, thread_id, is_explicit = _parse_target_ref("email", "user@example.com")
assert chat_id == "user@example.com"
assert thread_id is None
assert is_explicit is True
def test_email_with_dots_in_local_part(self):
chat_id, _, is_explicit = _parse_target_ref("email", "first.last@example.co.uk")
assert chat_id == "first.last@example.co.uk"
assert is_explicit is True
def test_email_with_plus_tag(self):
chat_id, _, is_explicit = _parse_target_ref("email", "user+tag@gmail.com")
assert chat_id == "user+tag@gmail.com"
assert is_explicit is True
def test_email_strips_whitespace(self):
chat_id, _, is_explicit = _parse_target_ref("email", " user@example.com ")
assert chat_id == "user@example.com"
assert is_explicit is True
def test_invalid_email_not_explicit(self):
assert _parse_target_ref("email", "not-an-email")[2] is False
assert _parse_target_ref("email", "@example.com")[2] is False
assert _parse_target_ref("email", "user@")[2] is False
assert _parse_target_ref("email", "user@.com")[2] is False
def test_email_not_explicit_for_other_platforms(self):
assert _parse_target_ref("telegram", "user@example.com")[2] is False
assert _parse_target_ref("discord", "user@example.com")[2] is False
assert _parse_target_ref("slack", "user@example.com")[2] is False
class TestEmailHomeChannelErrorHint:
"""The no-home-channel error for email points at the real env var.
Email reads its home channel from EMAIL_HOME_ADDRESS (gateway/config.py),
not the generic EMAIL_HOME_CHANNEL. The error guidance must name the
variable that is actually consulted so users who follow it succeed.
"""
def test_email_error_names_email_home_address(self):
email_cfg = SimpleNamespace(enabled=True, token="", extra={})
config = SimpleNamespace(
platforms={Platform.EMAIL: email_cfg},
get_home_channel=lambda _platform: None,
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "email",
"message": "hi",
}
)
)
assert "EMAIL_HOME_ADDRESS" in result["error"]
assert "EMAIL_HOME_CHANNEL" not in result["error"]
def test_non_email_platform_keeps_generic_home_channel_hint(self):
telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={})
config = SimpleNamespace(
platforms={Platform.TELEGRAM: telegram_cfg},
get_home_channel=lambda _platform: None,
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "telegram",
"message": "hi",
}
)
)
assert "TELEGRAM_HOME_CHANNEL" in result["error"]
class TestSendDiscordThreadId:
"""_send_discord uses thread_id when provided."""
+82
View File
@@ -845,3 +845,85 @@ class TestResetBundledSkill:
post_manifest = _read_manifest()
assert "google-workspace" in post_manifest
assert (skills_dir / "productivity" / "google-workspace" / "SKILL.md").exists()
def test_reset_restore_succeeds_on_readonly_nix_tree(self, tmp_path):
"""#34972: --restore must succeed even when the user copy is a fully
read-only tree (r-xr-xr-x dirs + files), as produced by copying a
Nix-store source. The manifest is re-baselined and bundled re-copied."""
import os
import stat
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
dest = skills_dir / "productivity" / "google-workspace"
sub = dest / "references"
sub.mkdir(parents=True)
(dest / "SKILL.md").write_text("# user version\n")
(sub / "ref.md").write_text("# nested ref\n")
manifest_file.write_text(
"google-workspace:STALEHASH000000000000000000000000\n"
)
# Read-only files AND directories — the real Nix-store case.
ro_dir = (
stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP
| stat.S_IROTH | stat.S_IXOTH
)
os.chmod(sub / "ref.md", stat.S_IREAD)
os.chmod(dest / "SKILL.md", stat.S_IREAD)
os.chmod(sub, ro_dir)
os.chmod(dest, ro_dir)
try:
with self._patches(bundled, skills_dir, manifest_file):
result = reset_bundled_skill("google-workspace", restore=True)
assert result["ok"] is True
assert result["action"] == "restored"
# Bundled version was re-copied over the (deleted) user copy.
assert "upstream" in (dest / "SKILL.md").read_text()
# The read-only nested user dir/file was fully removed, not left behind.
assert not (sub / "ref.md").exists()
# sync ran and re-copied the skill (not stuck in limbo).
assert "google-workspace" in result["synced"]["copied"]
finally:
# Restore perms so tmp_path teardown can remove anything left.
for p in (sub, dest):
if p.exists():
os.chmod(p, stat.S_IRWXU)
def test_reset_restore_preserves_manifest_on_rmtree_failure(self, tmp_path):
"""#34972: when the user copy genuinely cannot be removed, the manifest
entry must NOT be deleted — otherwise the skill enters a limbo state
where future syncs silently skip it forever."""
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
dest = skills_dir / "productivity" / "google-workspace"
dest.mkdir(parents=True)
(dest / "SKILL.md").write_text("# user version\n")
manifest_file.write_text(
"google-workspace:STALEHASH000000000000000000000000\n"
)
# Simulate an unremovable tree (e.g. a busy mountpoint or a path even
# chmod can't rescue) by making the removal helper raise.
def _boom(_path):
raise PermissionError(13, "Permission denied")
with self._patches(bundled, skills_dir, manifest_file), patch(
"tools.skills_sync._rmtree_writable", side_effect=_boom
):
result = reset_bundled_skill("google-workspace", restore=True)
# Restore failed, and the manifest must be left untouched.
assert result["ok"] is False
assert result["action"] == "not_reset"
assert "Manifest entry preserved" in result["message"]
manifest_after = manifest_file.read_text()
assert "google-workspace" in manifest_after
# User copy is still on disk (we changed nothing).
assert (dest / "SKILL.md").exists()
+81
View File
@@ -917,3 +917,84 @@ class TestIsImageSizeError:
def test_empty_message(self):
assert not _is_image_size_error(Exception(""))
class TestDownloadRetryClassification:
"""Error-class-aware retry: 4xx fail-fast, 429/5xx/transient retried (issue #32296)."""
@staticmethod
def _status_error(status_code):
import httpx
request = httpx.Request("GET", "https://example.com/img.jpg")
response = httpx.Response(status_code, request=request)
return httpx.HTTPStatusError(
f"{status_code}", request=request, response=response
)
def _make_client_raising_status(self, status_code):
"""AsyncClient whose response.raise_for_status() raises HTTPStatusError."""
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock(
side_effect=self._status_error(status_code)
)
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=mock_response)
return mock_client
def test_is_retryable_classification(self):
from tools.vision_tools import _is_retryable_download_error
# Non-retryable client errors
for code in (400, 403, 404, 410):
assert _is_retryable_download_error(self._status_error(code)) is False
# Retryable: rate limit + server errors
for code in (429, 500, 502, 503):
assert _is_retryable_download_error(self._status_error(code)) is True
# Policy/SSRF/size errors are terminal
assert _is_retryable_download_error(PermissionError("blocked")) is False
assert _is_retryable_download_error(ValueError("too large")) is False
# Unclassified (network blip) is retryable
assert _is_retryable_download_error(ConnectionError("reset")) is True
@pytest.mark.asyncio
async def test_404_fails_fast_without_retry(self, tmp_path):
"""A 404 must raise on the first attempt — no backoff sleep, no extra GETs."""
import httpx
from tools.vision_tools import _download_image
mock_client = self._make_client_raising_status(404)
with (
patch("tools.vision_tools.httpx.AsyncClient", return_value=mock_client),
patch("tools.vision_tools.check_website_access", return_value=None),
patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
pytest.raises(httpx.HTTPStatusError),
):
await _download_image(
"https://example.com/missing.jpg", tmp_path / "x.jpg", max_retries=3
)
# Exactly one attempt, zero backoff sleeps.
assert mock_client.get.await_count == 1
mock_sleep.assert_not_called()
@pytest.mark.asyncio
async def test_503_retries_then_raises(self, tmp_path):
"""A 5xx is retried up to max_retries, sleeping between attempts."""
import httpx
from tools.vision_tools import _download_image
mock_client = self._make_client_raising_status(503)
with (
patch("tools.vision_tools.httpx.AsyncClient", return_value=mock_client),
patch("tools.vision_tools.check_website_access", return_value=None),
patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
pytest.raises(httpx.HTTPStatusError),
):
await _download_image(
"https://example.com/flaky.jpg", tmp_path / "y.jpg", max_retries=3
)
# All three attempts used, two backoff sleeps between them.
assert mock_client.get.await_count == 3
assert mock_sleep.await_count == 2
+41 -2
View File
@@ -623,10 +623,49 @@ class TestCheckWebApiKey:
assert check_web_api_key() is True
def test_tool_gateway_returns_true(self):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools._peek_nous_access_token", return_value="nous-token"):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_tool_gateway_availability_skips_refresh_for_expired_cached_token(
self,
tmp_path,
monkeypatch,
):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expired_at = "2000-01-01T00:00:00+00:00"
(tmp_path / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "expired-token",
"refresh_token": "refresh-token",
"expires_at": expired_at,
}
}
}))
refresh_calls = []
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
refresh_calls.append(refresh_skew_seconds)
return "fresh-token"
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
_record_refresh,
)
with patch.dict(
os.environ,
{"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"},
clear=False,
):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
assert refresh_calls == []
def test_configured_backend_must_match_available_provider(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
@@ -636,7 +675,7 @@ class TestCheckWebApiKey:
def test_configured_firecrawl_backend_accepts_managed_gateway(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools._peek_nous_access_token", return_value="nous-token"):
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True