hermes-agent/tests/tools/test_managed_browserbase_and_modal.py
Teknium 28524adb0e fix(tests): eliminate flaky/broken tests — shadow sys.path inserts, unmocked network in compressor tests, stale-SDK feishu pin guard, quadratic redact regexes
- Remove tests/-shadowing sys.path.insert(dirname/'..') from 11 test files:
  it prepended the tests/ dir itself to sys.path, so 'import agent' /
  'import hermes_cli' resolved to the test packages and collection died
  with ModuleNotFoundError depending on import order (2 files failed in
  every full-suite run; 9 more were latent).
- Patch call_llm in 5 context-compressor tests that called compress()
  unmocked: each burned ~50s attempting live LLM traffic through the
  relay before falling back (572s file — the slowest in the suite, and
  flaky under the 300s per-file timeout). File now runs in ~5s.
- agent/redact.py: fix two catastrophically-backtracking regexes hit by
  the compressor's redaction pass on large payloads —
  _STRICT_URL_USERINFO_RE anchors on the mandatory '//' (optional-scheme
  prefix backtracked O(n^2): ~55s on a 320KB payload, now sub-ms;
  output-equivalence fuzz-verified on 20k random strings), and the
  _CFG_DOTTED_RE/_CFG_ANCHORED_RE subs gain an exact linear keyword
  pre-gate so secret-free text skips the quadratic pattern entirely.
- tests/gateway/test_feishu.py: version-guard the extra_ua_tags SDK
  signature check; the repo pins lark-oapi==1.6.8 but stale local
  installs (1.5.3) fail the assertion — skip below the pin.
- tests/tools/test_managed_browserbase_and_modal.py: stub
  agent.redact + agent.credential_persistence in the fake agent package
  (empty __path__ blocks all real agent.* imports added since the fake
  was written).
- tests/gateway/test_startup_restart_race.py: raise wait_for timeouts
  2s -> 30s; 2s wall-clock on a loaded 40-worker box flaked in the
  baseline run (passes instantly when the box is quiet).
2026-07-29 15:12:28 -07:00

299 lines
12 KiB
Python

import os
import sys
import tempfile
import threading
import types
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli.nous_account import NousPortalAccountInfo
REPO_ROOT = Path(__file__).resolve().parents[2]
TOOLS_DIR = REPO_ROOT / "tools"
PLUGINS_DIR = REPO_ROOT / "plugins"
def _load_tool_module(module_name: str, filename: str):
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
assert spec and spec.loader
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _load_plugin_module(module_name: str, relpath: str):
"""Load a plugin module by file path from ``plugins/``.
Mirror of :func:`_load_tool_module` for the plugin tree. Used by tests
that exercise the per-vendor browser plugins' session-lifecycle
behaviour after the PR #25214 migration.
"""
spec = spec_from_file_location(module_name, PLUGINS_DIR / relpath)
assert spec and spec.loader
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if name.startswith(prefixes):
sys.modules.pop(name, None)
@pytest.fixture(autouse=True)
def _restore_tool_and_agent_modules():
original_modules = {
name: module
for name, module in sys.modules.items()
if name == "tools"
or name.startswith("tools.")
or name == "agent"
or name.startswith("agent.")
}
try:
yield
finally:
_reset_modules(("tools", "agent"))
sys.modules.update(original_modules)
@pytest.fixture(autouse=True)
def _enable_managed_nous_tools(monkeypatch):
"""Ensure managed_nous_tools_enabled() returns True even after module reloads.
The _install_fake_tools_package() helper resets and reimports tool modules,
so a simple monkeypatch on tool_backend_helpers doesn't survive. We patch
the *source* modules that the reimported modules will import from — both
hermes_cli.nous_account — so the function body returns True.
"""
monkeypatch.setattr(
"hermes_cli.nous_account.get_nous_portal_account_info",
lambda: NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
),
)
def _install_fake_tools_package():
_reset_modules(("tools", "agent"))
tools_package = types.ModuleType("tools")
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
sys.modules["tools"] = tools_package
env_package = types.ModuleType("tools.environments")
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
sys.modules["tools.environments"] = env_package
agent_package = types.ModuleType("agent")
agent_package.__path__ = [] # type: ignore[attr-defined]
sys.modules["agent"] = agent_package
sys.modules["agent.auxiliary_client"] = types.SimpleNamespace(
call_llm=lambda *args, **kwargs: "",
)
# The fake `agent` package has an empty __path__, so every real
# agent.* submodule that production code imports needs an explicit
# stand-in here. tools.browser_tool imports redact_cdp_url;
# hermes_cli.auth (imported transitively by nous_account /
# tool_backend_helpers) imports sanitize_borrowed_credential_payload.
sys.modules["agent.redact"] = types.SimpleNamespace(
redact_cdp_url=lambda value: str(value),
)
sys.modules["agent.credential_persistence"] = types.SimpleNamespace(
sanitize_borrowed_credential_payload=lambda entry, provider_id=None: entry,
)
# Stubs for the browser-provider plugin layer introduced in PR #25214.
# The fake `agent` package has an empty __path__ so real submodules
# aren't reachable; we install just enough stand-ins to satisfy
# ``tools.browser_tool``'s top-level imports. The actual lifecycle
# tests instantiate the real plugin classes via _load_tool_module
# below, so the stubs only need to satisfy import + isinstance.
class _StubBrowserProvider:
"""Minimal BrowserProvider stub for ``from agent.browser_provider import BrowserProvider``."""
sys.modules["agent.browser_provider"] = types.SimpleNamespace(
BrowserProvider=_StubBrowserProvider,
)
sys.modules["agent.browser_registry"] = types.SimpleNamespace(
get_provider=lambda name: None,
list_providers=lambda: [],
register_provider=lambda provider: None,
_resolve=lambda configured: None,
)
# Plugin module stubs — the real plugin classes are loaded from disk by
# the lifecycle tests below via _load_tool_module(). For the import
# phase, we just need the class names to exist on the right module path.
plugins_package = types.ModuleType("plugins")
plugins_package.__path__ = [] # type: ignore[attr-defined]
sys.modules["plugins"] = plugins_package
plugins_browser_package = types.ModuleType("plugins.browser")
plugins_browser_package.__path__ = [] # type: ignore[attr-defined]
sys.modules["plugins.browser"] = plugins_browser_package
for _name, _classname in (
("browserbase", "BrowserbaseBrowserProvider"),
("browser_use", "BrowserUseBrowserProvider"),
("firecrawl", "FirecrawlBrowserProvider"),
):
_vendor_pkg = types.ModuleType(f"plugins.browser.{_name}")
_vendor_pkg.__path__ = [] # type: ignore[attr-defined]
sys.modules[f"plugins.browser.{_name}"] = _vendor_pkg
_provider_stub_cls = type(_classname, (_StubBrowserProvider,), {})
sys.modules[f"plugins.browser.{_name}.provider"] = types.SimpleNamespace(
**{_classname: _provider_stub_cls},
)
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
"tools.managed_tool_gateway",
"managed_tool_gateway.py",
)
interrupt_event = threading.Event()
sys.modules["tools.interrupt"] = types.SimpleNamespace(
set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(),
is_interrupted=lambda: interrupt_event.is_set(),
_interrupt_event=interrupt_event,
)
sys.modules["tools.approval"] = types.SimpleNamespace(
detect_dangerous_command=lambda *args, **kwargs: None,
check_dangerous_command=lambda *args, **kwargs: {"approved": True},
check_all_command_guards=lambda *args, **kwargs: {"approved": True},
load_permanent_allowlist=lambda *args, **kwargs: [],
DANGEROUS_PATTERNS=[],
)
class _Registry:
def register(self, **kwargs):
return None
from tools.registry import tool_error
sys.modules["tools.registry"] = types.SimpleNamespace(
registry=_Registry(), tool_error=tool_error,
)
class _DummyEnvironment:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def cleanup(self):
return None
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyEnvironment)
sys.modules["tools.environments.local"] = types.SimpleNamespace(LocalEnvironment=_DummyEnvironment)
sys.modules["tools.environments.singularity"] = types.SimpleNamespace(
_get_scratch_dir=lambda: Path(tempfile.gettempdir()),
SingularityEnvironment=_DummyEnvironment,
)
sys.modules["tools.environments.ssh"] = types.SimpleNamespace(SSHEnvironment=_DummyEnvironment)
sys.modules["tools.environments.docker"] = types.SimpleNamespace(DockerEnvironment=_DummyEnvironment)
sys.modules["tools.environments.modal"] = types.SimpleNamespace(ModalEnvironment=_DummyEnvironment)
sys.modules["tools.environments.managed_modal"] = types.SimpleNamespace(ManagedModalEnvironment=_DummyEnvironment)
def test_browser_use_explicit_local_mode_stays_local_even_when_managed_gateway_is_ready(tmp_path):
_install_fake_tools_package()
(tmp_path / "config.yaml").write_text("browser:\n cloud_provider: local\n", encoding="utf-8")
env = os.environ.copy()
env.pop("BROWSER_USE_API_KEY", None)
env.update({
"HERMES_HOME": str(tmp_path),
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
"BROWSER_USE_GATEWAY_URL": "http://127.0.0.1:3009",
})
with patch.dict(os.environ, env, clear=True):
browser_tool = _load_tool_module("tools.browser_tool", "browser_tool.py")
local_mode = browser_tool._is_local_mode()
provider = browser_tool._get_cloud_provider()
assert local_mode is True
assert provider is None
def test_browserbase_does_not_use_gateway_only_configuration():
_install_fake_tools_package()
env = os.environ.copy()
env.pop("BROWSERBASE_API_KEY", None)
env.pop("BROWSERBASE_PROJECT_ID", None)
env.update({
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
})
with patch.dict(os.environ, env, clear=True):
browserbase_module = _load_plugin_module(
"plugins.browser.browserbase.provider",
"browser/browserbase/provider.py",
)
provider = browserbase_module.BrowserbaseBrowserProvider()
assert provider.is_available() is False
def test_terminal_tool_respects_direct_modal_mode_without_falling_back_to_managed():
_install_fake_tools_package()
env = os.environ.copy()
env.pop("MODAL_TOKEN_ID", None)
env.pop("MODAL_TOKEN_SECRET", None)
with patch.dict(os.environ, env, clear=True):
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
with (
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
patch.object(Path, "exists", return_value=False),
):
with pytest.raises(ValueError, match="direct Modal credentials"):
terminal_tool._create_environment(
env_type="modal",
image="python:3.11",
cwd="/root",
timeout=60,
container_config={
"container_cpu": 1,
"container_memory": 2048,
"container_disk": 1024,
"container_persistent": True,
"modal_mode": "direct",
},
task_id="task-modal-direct-only",
)
class TestShellEscapeBypass:
"""Regression for #36846/#36847: backslash escapes and empty-string
literals split tokens so a denylisted command (rm) slips past detection
while the shell still executes it."""
def test_backslash_escape_bypass_caught(self):
from tools.approval import detect_dangerous_command
# literal: r-backslash-m -rf / (shell collapses r\m -> rm)
assert detect_dangerous_command("r\\m -rf /")[0] is True
def test_empty_string_literal_bypass_caught(self):
from tools.approval import detect_dangerous_command
assert detect_dangerous_command("r''m -rf /")[0] is True
assert detect_dangerous_command('r""m -rf /')[0] is True
def test_plain_dangerous_still_caught(self):
from tools.approval import detect_dangerous_command
assert detect_dangerous_command("rm -rf /")[0] is True
def test_benign_command_not_flagged(self):
from tools.approval import detect_dangerous_command
assert detect_dangerous_command("ls -la")[0] is False