mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
Greptile finding "Browser recommends a missing tool": the non-native
browser_exec header picked its vision_analyze pointer from
check_vision_requirements(), which reports whether a vision route exists,
not whether this session exposes the tool, so a session with `vision`
disabled was told to call a tool it does not have (tools/AGENTS.md:
cross-toolset references are added in get_tool_definitions()).
The header now states only the transport fact ("Screenshots are not
attached to this tool's results. Work text-first: ..."), still never
calling the model blind, and model_tools' browser_exec rewriter appends the
vision_analyze pointer when "vision_analyze" is in the session's available
set. The native header (screenshots ride back in the result) never gets it.
Prompt-surface fixtures re-rendered: the stock render has vision_analyze,
so browser_exec gains the pointer (+8 tokens per surface).
1123 lines
52 KiB
Python
1123 lines
52 KiB
Python
"""Tests for ``browser_exec`` (tools/browser_use_cli.py), Hermes' only core browser tool.
|
|
|
|
Covers the seams the integration relies on:
|
|
|
|
* Routing — every browser comes from ``tools.browser_session`` in a fixed precedence
|
|
(BU_CDP_* env > CDP override > hybrid ``::local`` sidecar > cloud provider > local engine);
|
|
Browser Use direct-API configs keep the CLI's native cloud autospawn.
|
|
* Tool surface — ``browser_exec`` needs the terminal surface.
|
|
* ``browser_exec`` execution — code is piped on stdin, ``session`` becomes
|
|
``BU_NAME``, bad session names and a missing CLI produce actionable errors.
|
|
"""
|
|
import json
|
|
import os
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
import psutil
|
|
|
|
import tools.browser_use_cli as bu_cli
|
|
from tools import browser_session as bs
|
|
from tools import browser_session_cloud as bs_cloud
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(monkeypatch):
|
|
monkeypatch.delenv("BU_NAME", raising=False)
|
|
monkeypatch.delenv("BU_AUTOSPAWN", raising=False)
|
|
monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False)
|
|
bs_cloud.reset_caches()
|
|
yield
|
|
bs_cloud.reset_caches()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fake_managed_chromium(monkeypatch):
|
|
"""The local route asks the session layer for this key's local browser (packaged Chromium via the
|
|
launcher); never launch a real browser from a unit test. Records every session key requested."""
|
|
calls = []
|
|
|
|
def fake_session_info(key):
|
|
calls.append(key)
|
|
return {"cdp_url": f"ws://127.0.0.1:47000/devtools/browser/{key}", "features": {"local": True}}
|
|
|
|
monkeypatch.setattr(bs, "get_session_info", fake_session_info)
|
|
return calls
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fake_supervisor_registry(monkeypatch):
|
|
"""browser_exec attaches the vault supervisor to the resolved CDP endpoint; the fake endpoint above is
|
|
not a real browser, so record the attach instead of opening a WebSocket (15 s start timeout)."""
|
|
from tools import browser_supervisor
|
|
|
|
attached = []
|
|
|
|
class _Registry:
|
|
def get_or_start(self, task_id, cdp_url, **kw):
|
|
attached.append((task_id, cdp_url))
|
|
|
|
def get(self, task_id): # browser_exec reads pending dialogs from the task's supervisor
|
|
return None
|
|
|
|
monkeypatch.setattr(browser_supervisor, "SUPERVISOR_REGISTRY", _Registry())
|
|
return attached
|
|
|
|
|
|
def _fake_cli(tmp_path, body):
|
|
"""Write an executable fake browser-use CLI and return its path."""
|
|
script = tmp_path / "browser-use"
|
|
script.write_text("#!/bin/sh\n" + body, encoding="utf-8")
|
|
script.chmod(script.stat().st_mode | stat.S_IXUSR)
|
|
return str(script)
|
|
|
|
|
|
|
|
class TestSubprocessEnvironment:
|
|
def test_browser_use_telemetry_defaults_off(self, monkeypatch):
|
|
monkeypatch.setattr(bs, "build_browser_env", dict)
|
|
env = bu_cli._base_subprocess_env()
|
|
assert env["ANONYMIZED_TELEMETRY"] == "false"
|
|
|
|
def test_subprocess_env_replaces_parent_python_import_paths(self, monkeypatch):
|
|
"""#83427/#84841/#86006/#86104: an inherited PYTHONPATH/PYTHONHOME must never reach the
|
|
harness child; PYTHONPATH is replaced by the harness's own site dir, unrelated vars survive."""
|
|
monkeypatch.setattr(bs, "build_browser_env", lambda: {
|
|
"PYTHONPATH": "/hermes:/hermes/venv/lib/site-packages",
|
|
"PYTHONHOME": "/hermes/venv",
|
|
"KEEP_ME": "yes",
|
|
})
|
|
monkeypatch.setattr(bu_cli, "_harness_site_dir", lambda: "/harness-site")
|
|
|
|
env = bu_cli._base_subprocess_env()
|
|
|
|
assert env["PYTHONPATH"] == "/harness-site"
|
|
assert "PYTHONHOME" not in env
|
|
assert env["KEEP_ME"] == "yes"
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX PATH-floor semantics")
|
|
def test_subprocess_env_floors_version_manager_only_path(self, monkeypatch):
|
|
"""Profile workers (kanban bots, cron) can inherit a PATH of only
|
|
version-manager dirs (observed in the wild: one nvm dir repeated
|
|
7x). The uv browser-use trampoline resolves dirname/realpath
|
|
through PATH, so /usr/bin must be guaranteed or the CLI dies
|
|
'realpath: not found' (exit 127) before its Python starts."""
|
|
monkeypatch.setattr(bs, "build_browser_env", lambda: {
|
|
"PATH": os.pathsep.join(
|
|
["/home/u/.nvm/versions/node/v24.18.0/bin"] * 7
|
|
),
|
|
})
|
|
|
|
env = bu_cli._base_subprocess_env()
|
|
|
|
parts = env["PATH"].split(os.pathsep)
|
|
assert "/usr/bin" in parts
|
|
assert "/bin" in parts
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX PATH-floor semantics")
|
|
def test_floor_preserves_existing_entries_and_order(self):
|
|
"""The floor only adds dirs — never drops or reorders what the
|
|
caller's environment already had."""
|
|
original = "/opt/toolchain/bin:/usr/bin:/snap/bin"
|
|
merged = bu_cli._floor_subprocess_path(original).split(os.pathsep)
|
|
|
|
assert set(original.split(os.pathsep)) <= set(merged)
|
|
positions = [merged.index(p) for p in original.split(os.pathsep)]
|
|
assert positions == sorted(positions)
|
|
|
|
|
|
|
|
class TestToolSurfaceSwap:
|
|
def test_browser_exec_stripped_without_terminal(self, monkeypatch):
|
|
"""Sessions without the terminal surface must not regain host code
|
|
execution through browser_exec (arbitrary Python via the CLI)."""
|
|
from tools.registry import registry
|
|
|
|
entry = registry.get_entry("browser_exec")
|
|
monkeypatch.setattr(entry, "check_fn", lambda: True)
|
|
import model_tools
|
|
|
|
defs = model_tools.get_tool_definitions(
|
|
enabled_toolsets=["browser"], quiet_mode=False
|
|
)
|
|
names = {t["function"]["name"] for t in defs}
|
|
assert "browser_exec" not in names
|
|
|
|
def test_browser_exec_present_with_terminal(self, monkeypatch):
|
|
from tools.registry import registry
|
|
|
|
entry = registry.get_entry("browser_exec")
|
|
monkeypatch.setattr(entry, "check_fn", lambda: True)
|
|
import model_tools
|
|
|
|
defs = model_tools.get_tool_definitions(
|
|
enabled_toolsets=["browser", "terminal"], quiet_mode=False
|
|
)
|
|
names = {t["function"]["name"] for t in defs}
|
|
assert "browser_exec" in names
|
|
|
|
|
|
class TestVaultSupervisorAttach:
|
|
def test_exec_attaches_supervisor_to_the_browser_it_drives(self, tmp_path, monkeypatch, _fake_supervisor_registry):
|
|
"""browser_vault_fill injects secrets only over the supervisor's CDP WebSocket. Without this attach the
|
|
default (Browser Use) backend had no supervisor at all and every fill failed with supervisor_required."""
|
|
monkeypatch.setattr("hermes_cli.config_effective.load_user_config_effective", lambda: {"browser": {}})
|
|
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho ok\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
monkeypatch.setattr("tools.browser_session_cdp.resolve_cdp_override", lambda url: url)
|
|
|
|
result = json.loads(bu_cli.browser_exec("print(1)", task_id="t-vault"))
|
|
|
|
assert result["success"] is True
|
|
assert _fake_supervisor_registry == [("t-vault", "ws://127.0.0.1:47000/devtools/browser/t-vault")]
|
|
|
|
|
|
class TestBackendSwapRetargetsDaemons:
|
|
def test_browser_swap_restarts_every_daemon_on_the_new_endpoint(self, tmp_path, monkeypatch):
|
|
"""/browser connect|disconnect swap the endpoint inside cleanup_all_browsers(). The harness daemon
|
|
latches BU_CDP_* when it starts and outlives every call, so without a stop there every later
|
|
browser_exec, named or not, kept driving the browser from before the swap."""
|
|
from tools.browser_session_lifecycle import cleanup_all_browsers
|
|
|
|
# Like browser-harness: one daemon per BU_NAME latches its first endpoint; --reload stops it.
|
|
cli = _fake_cli(tmp_path, f'''
|
|
latch="{tmp_path}/daemon-${{BU_NAME:-default}}"
|
|
if [ "${{1:-}}" = "--reload" ]; then rm -f "$latch"; exit 0; fi
|
|
cat > /dev/null
|
|
[ -f "$latch" ] || echo "$BU_CDP_URL" > "$latch"
|
|
cat "$latch"
|
|
''')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
monkeypatch.setattr("tools.browser_session_cdp.resolve_cdp_override", lambda url: url)
|
|
drive = lambda **kw: json.loads(bu_cli.browser_exec("print(1)", task_id="t-swap", **kw))["output"].strip()
|
|
|
|
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9400")
|
|
assert drive() == drive(session="research") == "http://127.0.0.1:9400"
|
|
|
|
cleanup_all_browsers()
|
|
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9401")
|
|
assert drive() == drive(session="research") == "http://127.0.0.1:9401"
|
|
|
|
|
|
class TestVaultEgressRedaction:
|
|
def test_exec_redacts_registered_vault_secret_from_stdout_and_stderr(self, tmp_path, monkeypatch):
|
|
"""A browser_exec page read must not return a vault-filled value to model history."""
|
|
from agent import redact
|
|
|
|
secret = "vault-filled-password-112693"
|
|
cli = _fake_cli(tmp_path, f'cat > /dev/null\necho "stdout={secret}"\necho "stderr={secret}" >&2\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
redact.register_vault_redaction_value(secret)
|
|
try:
|
|
result = json.loads(bu_cli.browser_exec("print(1)"))
|
|
finally:
|
|
redact.clear_vault_redaction_values()
|
|
|
|
serialized = json.dumps(result, ensure_ascii=False)
|
|
assert secret not in serialized
|
|
assert serialized.count("«redacted-vault-secret»") == 2
|
|
|
|
|
|
|
|
class TestLegacyCloudMigration:
|
|
"""Pre-CLI direct-API Browser Use cloud configs (cloud_provider:
|
|
"browser-use" + BROWSER_USE_API_KEY) run on the CLI's native Browser Use
|
|
cloud (BU_AUTOSPAWN); Nous-gateway users stay on the provider path."""
|
|
|
|
_LEGACY = {"browser": {"cloud_provider": "browser-use"}}
|
|
|
|
def test_direct_api_config_migrates(self, monkeypatch):
|
|
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
|
|
assert bu_cli.is_legacy_browser_use_cloud_config(self._LEGACY["browser"]) is True
|
|
|
|
def test_gateway_config_stays_on_legacy_path(self, monkeypatch):
|
|
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
|
|
assert bu_cli.is_legacy_browser_use_cloud_config(
|
|
{"cloud_provider": "browser-use", "use_gateway": True}) is False
|
|
|
|
def test_no_api_key_stays_on_legacy_path(self, monkeypatch):
|
|
assert bu_cli.is_legacy_browser_use_cloud_config(self._LEGACY["browser"]) is False
|
|
|
|
def test_other_cloud_provider_does_not_migrate(self, monkeypatch):
|
|
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
|
|
assert bu_cli.is_legacy_browser_use_cloud_config({"cloud_provider": "browserbase"}) is False
|
|
|
|
|
|
def test_auto_detect_with_key_migrates(self, monkeypatch):
|
|
"""No cloud_provider configured + BROWSER_USE_API_KEY set: credential
|
|
auto-detection prefers Browser Use (even when Browserbase creds are
|
|
also present), which means the CLI's native Browser Use cloud."""
|
|
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
|
|
monkeypatch.setenv("BROWSERBASE_API_KEY", "bb-key")
|
|
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "bb-project")
|
|
assert bu_cli.is_legacy_browser_use_cloud_config({}) is True
|
|
|
|
|
|
def test_migrated_config_gets_bu_autospawn(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.config_effective.load_user_config_effective", lambda: self._LEGACY)
|
|
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
|
|
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "autospawn:$BU_AUTOSPAWN"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print(1)"))
|
|
assert "autospawn:1" in result["output"]
|
|
|
|
def test_non_legacy_config_does_not_set_bu_autospawn(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config_effective.load_user_config_effective",
|
|
lambda: {"browser": {}},
|
|
)
|
|
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "autospawn:[$BU_AUTOSPAWN]"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print(1)"))
|
|
assert "autospawn:[]" in result["output"]
|
|
|
|
|
|
class TestBackendCdpResolution:
|
|
"""browser_exec routes through the configured browser backend via the session layer
|
|
(browser_session.get_session_info)."""
|
|
|
|
def _env(self):
|
|
return {}
|
|
|
|
def test_existing_bu_env_wins(self, monkeypatch):
|
|
env = {"BU_CDP_WS": "ws://operator-override:9222"}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "ws://operator-override:9222"
|
|
|
|
def test_cdp_override_exported(self, monkeypatch):
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "http://127.0.0.1:9222")
|
|
env = self._env()
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_URL"] == "http://127.0.0.1:9222"
|
|
|
|
def test_ws_override_uses_bu_cdp_ws(self, monkeypatch):
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "wss://connect.example/x")
|
|
env = self._env()
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "wss://connect.example/x"
|
|
|
|
def test_cloud_provider_session_exported(self, monkeypatch):
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info",
|
|
lambda task_id: {"cdp_url": "wss://browser.example/cdp/abc"},
|
|
)
|
|
env = self._env()
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "wss://browser.example/cdp/abc"
|
|
|
|
def test_no_provider_drives_packaged_chromium_not_user_chrome(self, monkeypatch, _fake_managed_chromium):
|
|
"""Local mode must hand the harness the launcher's packaged Chromium (through the session layer)
|
|
— never leave BU_CDP_* unset, which makes the harness hunt for the user's installed
|
|
Chrome (Allow popup, >=136 default-profile block, chrome-not-running on headless hosts)."""
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: None)
|
|
env = self._env()
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "ws://127.0.0.1:47000/devtools/browser/t1"
|
|
assert env[bu_cli._PRIVATE_BROWSER_SENTINEL] == "1"
|
|
assert _fake_managed_chromium == ["t1"]
|
|
env = self._env()
|
|
assert bu_cli._resolve_backend_cdp(env, "t1", session_name="r7k2") is None
|
|
assert _fake_managed_chromium[-1] == "bu-named-r7k2" # named session → its own Chromium
|
|
|
|
def test_packaged_chromium_launch_failure_is_an_error(self, monkeypatch):
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: None)
|
|
def no_chromium(key):
|
|
raise RuntimeError("Chromium browser not installed")
|
|
monkeypatch.setattr(bs, "get_session_info", no_chromium)
|
|
env = self._env()
|
|
err = bu_cli._resolve_backend_cdp(env, "t1")
|
|
assert err and "Chromium browser not installed" in err
|
|
assert "BU_CDP_WS" not in env and "BU_CDP_URL" not in env
|
|
|
|
def test_provider_failure_returns_error(self, monkeypatch):
|
|
|
|
def boom(task_id):
|
|
raise RuntimeError("api down")
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
monkeypatch.setattr(bs, "get_session_info", boom)
|
|
err = bu_cli._resolve_backend_cdp(self._env(), "t1")
|
|
assert err and "api down" in err
|
|
|
|
def test_provider_fallback_to_local_drives_packaged_chromium(self, monkeypatch, _fake_managed_chromium):
|
|
"""A cloud failure the session layer already fell back from (429, quota) must drive the local
|
|
Chromium it chose, not fail the call as 'no CDP endpoint'."""
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
def fell_back(key):
|
|
_fake_managed_chromium.append(key)
|
|
return {"cdp_url": f"ws://127.0.0.1:47000/devtools/browser/{key}", "features": {"local": True},
|
|
"fallback_from_cloud": True, "fallback_reason": "429"}
|
|
monkeypatch.setattr(bs, "get_session_info", fell_back)
|
|
env = self._env()
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "ws://127.0.0.1:47000/devtools/browser/t1"
|
|
assert _fake_managed_chromium == ["t1"]
|
|
|
|
def test_provider_without_cdp_returns_error(self, monkeypatch):
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
monkeypatch.setattr(bs, "get_session_info", lambda task_id: {"cdp_url": None})
|
|
err = bu_cli._resolve_backend_cdp(self._env(), "t1")
|
|
assert err and "no" in err.lower() and "CDP" in err
|
|
|
|
def test_named_session_composes_with_provider_backend(self, tmp_path, monkeypatch):
|
|
"""session=<name> composes with a configured provider backend: the
|
|
name keys its OWN provider browser (bu-named-<name>), so concurrent
|
|
named sessions never share one browser (#86894)."""
|
|
|
|
seen = []
|
|
|
|
def fake_session_info(key):
|
|
seen.append(key)
|
|
return {"cdp_url": "wss://browser.example/cdp/" + key}
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
monkeypatch.setattr(bs, "get_session_info", fake_session_info)
|
|
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "bu:$BU_NAME ws:$BU_CDP_WS"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print(1)", session="r7k2"))
|
|
assert result["success"] is True
|
|
assert seen == ["bu-named-r7k2"]
|
|
assert "bu:r7k2" in result["output"]
|
|
assert "ws:wss://browser.example/cdp/bu-named-r7k2" in result["output"]
|
|
|
|
def test_named_session_key_stable_across_tasks(self, monkeypatch):
|
|
"""The same session name maps to the same provider cache key no
|
|
matter which task calls it — that is what lets a follow-up call
|
|
reattach to the same cloud browser."""
|
|
|
|
seen = []
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info",
|
|
lambda key: seen.append(key) or {"cdp_url": "wss://x/cdp/a"},
|
|
)
|
|
env1, env2 = {}, {}
|
|
assert bu_cli._resolve_backend_cdp(env1, "task-A", session_name="research") is None
|
|
assert bu_cli._resolve_backend_cdp(env2, "task-B", session_name="research") is None
|
|
assert seen == ["bu-named-research", "bu-named-research"]
|
|
|
|
def test_named_session_direct_api_bu_cloud_still_skips_provider(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Direct-API Browser Use cloud configs keep the native named-daemon
|
|
path: resolving through the provider would double-session and
|
|
double-bill."""
|
|
|
|
class _BUProvider:
|
|
name = "browser-use"
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: _BUProvider())
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info",
|
|
lambda key: (_ for _ in ()).throw(AssertionError("must skip provider")),
|
|
)
|
|
monkeypatch.setattr(bu_cli, "_read_browser_cfg", lambda: {"cloud_provider": "browser-use"})
|
|
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
|
|
env = {}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1", session_name="r7k2") is None
|
|
assert "BU_CDP_WS" not in env and "BU_CDP_URL" not in env
|
|
|
|
def test_direct_api_without_key_uses_session_path_not_chrome_hunt(self, monkeypatch, _fake_managed_chromium):
|
|
"""cloud_provider browser-use with no API key must not take the autospawn shortcut: the CLI
|
|
only autospawns with the key, so None left the harness hunting an installed Chrome
|
|
(DevToolsActivePort). The session path's local-Chromium fallback drives the packaged one."""
|
|
|
|
class _BUProvider:
|
|
name = "browser-use"
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: _BUProvider())
|
|
def fell_back(key):
|
|
_fake_managed_chromium.append(key)
|
|
return {"cdp_url": f"ws://127.0.0.1:47000/devtools/browser/{key}", "features": {"local": True},
|
|
"fallback_from_cloud": True, "fallback_reason": "BROWSER_USE_API_KEY is not set"}
|
|
monkeypatch.setattr(bs, "get_session_info", fell_back)
|
|
monkeypatch.setattr(bu_cli, "_read_browser_cfg", lambda: {"cloud_provider": "browser-use"})
|
|
env = self._env()
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "ws://127.0.0.1:47000/devtools/browser/t1"
|
|
assert _fake_managed_chromium == ["t1"]
|
|
|
|
def test_picker_managed_selection_resolves_gateway_provider(self, monkeypatch):
|
|
"""``cloud_provider: nous`` (the `hermes tools` managed row) must resolve through the
|
|
provider: the picker never writes the legacy ``use_gateway`` flag, and the direct-API
|
|
branch leaves browser_exec with no CDP endpoint at all (#108310)."""
|
|
class _BUProvider:
|
|
name = "browser-use"
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: _BUProvider())
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info",
|
|
lambda task_id: {"cdp_url": "wss://gateway.example/cdp/managed"},
|
|
)
|
|
monkeypatch.setattr(bu_cli, "_read_browser_cfg", lambda: {"cloud_provider": "nous"})
|
|
env = {}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "wss://gateway.example/cdp/managed"
|
|
|
|
def test_never_configured_autodetect_managed_provider_resolves_through_provider(self, monkeypatch):
|
|
"""A never-configured install (no ``browser.cloud_provider``, no BROWSER_USE_API_KEY) can
|
|
still get a ``browser-use`` provider back from autodetect — via the managed Nous gateway
|
|
token, which needs no API key. That provider DOES have a session/CDP path, so it must
|
|
resolve through the provider instead of the direct-API shortcut: the shortcut leaves
|
|
BU_CDP_* unset and the harness falls back to hunting the user's profile dirs
|
|
(chrome-not-running on a headless host with no ~/.config/chromium)."""
|
|
class _BUProvider:
|
|
name = "browser-use"
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: _BUProvider())
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info",
|
|
lambda key: {"cdp_url": "wss://gateway.example/cdp/autodetect"},
|
|
)
|
|
# Never configured: no cloud_provider selection at all.
|
|
monkeypatch.setattr(bu_cli, "_read_browser_cfg", dict)
|
|
env = {}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "wss://gateway.example/cdp/autodetect"
|
|
|
|
def test_legacy_use_gateway_flag_still_resolves_gateway_provider(self, monkeypatch):
|
|
"""Regression guard for the pre-picker shape of the same selection."""
|
|
class _BUProvider:
|
|
name = "browser-use"
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: _BUProvider())
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info",
|
|
lambda task_id: {"cdp_url": "wss://gateway.example/cdp/legacy"},
|
|
)
|
|
monkeypatch.setattr(bu_cli, "_read_browser_cfg", lambda: {"use_gateway": True})
|
|
env = {}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "wss://gateway.example/cdp/legacy"
|
|
|
|
|
|
class TestOwnTabPreamble:
|
|
"""Named sessions on SHARED browsers (a /browser connect CDP override) get the own-tab preamble
|
|
prepended; private per-name browsers (packaged Chromium, provider) and unnamed sessions do not."""
|
|
|
|
def _run(self, tmp_path, monkeypatch, *, session="", private=False, provider=False, shared_cdp=""):
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: shared_cdp)
|
|
if provider:
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info",
|
|
lambda key: {"cdp_url": "wss://browser.example/cdp/" + key},
|
|
)
|
|
else:
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: None)
|
|
# fake CLI echoes stdin back so we can inspect what code was sent
|
|
cli = _fake_cli(tmp_path, "cat\n")
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
return json.loads(bu_cli.browser_exec("print('payload')", session=session))
|
|
|
|
def test_named_shared_browser_gets_preamble(self, tmp_path, monkeypatch):
|
|
result = self._run(tmp_path, monkeypatch, session="r7k2", shared_cdp="http://127.0.0.1:9222")
|
|
assert result["success"] is True
|
|
assert "_hermes_ensure_own_tab" in result["output"]
|
|
# model code still present, after the preamble
|
|
assert result["output"].index("_hermes_ensure_own_tab") < result["output"].index("print('payload')")
|
|
|
|
def test_named_packaged_chromium_skips_preamble(self, tmp_path, monkeypatch):
|
|
"""Each named session launches its own packaged Chromium — nothing to share a tab with."""
|
|
result = self._run(tmp_path, monkeypatch, session="r7k2")
|
|
assert result["success"] is True
|
|
assert "_hermes_ensure_own_tab" not in result["output"]
|
|
|
|
def test_unnamed_session_gets_no_preamble(self, tmp_path, monkeypatch):
|
|
result = self._run(tmp_path, monkeypatch, session="")
|
|
assert result["success"] is True
|
|
assert "_hermes_ensure_own_tab" not in result["output"]
|
|
|
|
def test_named_provider_browser_skips_preamble(self, tmp_path, monkeypatch):
|
|
"""Per-name provider browsers are private — preamble would leak a tab."""
|
|
result = self._run(tmp_path, monkeypatch, session="r7k2", provider=True)
|
|
assert result["success"] is True
|
|
assert "_hermes_ensure_own_tab" not in result["output"]
|
|
|
|
def test_sentinel_never_reaches_subprocess_env(self, tmp_path, monkeypatch):
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info",
|
|
lambda key: {"cdp_url": "wss://browser.example/cdp/" + key},
|
|
)
|
|
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "sentinel:${_HERMES_BU_PRIVATE_BROWSER:-unset}"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print(1)", session="r7k2"))
|
|
assert "sentinel:unset" in result["output"]
|
|
|
|
def test_preamble_is_valid_python(self):
|
|
import ast
|
|
|
|
ast.parse(bu_cli._OWN_TAB_PREAMBLE)
|
|
# and composes with model code
|
|
ast.parse(bu_cli._OWN_TAB_PREAMBLE + "print('x')")
|
|
|
|
|
|
class TestProviderPickerIntegration:
|
|
"""The `hermes tools` Browser Automation picker rows."""
|
|
|
|
def test_picker_row_names_stay_unique(self):
|
|
"""The CLI row is named "Browser Use"; the legacy plugin API row must
|
|
keep a distinct name — apply_provider_selection matches by name."""
|
|
from hermes_cli.tools_config import TOOL_CATEGORIES, _plugin_browser_providers
|
|
|
|
names = [r["name"] for r in TOOL_CATEGORIES["browser"]["providers"]]
|
|
names += [r["name"] for r in _plugin_browser_providers()]
|
|
assert len(names) == len(set(names))
|
|
|
|
|
|
class TestBrowserSlashDispatch:
|
|
"""/browser routes through the _BROWSER_SUBCOMMANDS table: the subcommand word is
|
|
case-insensitive, the argument keeps its case (CDP URL paths are case-sensitive),
|
|
and an unknown word prints the usage block without touching any handler."""
|
|
|
|
def _run(self, cmd, monkeypatch):
|
|
import contextlib
|
|
import io
|
|
|
|
import hermes_cli.cli_commands_mixin as mod
|
|
|
|
calls = []
|
|
monkeypatch.setattr(mod, "_browser_connect", lambda cli, url: calls.append(("connect", url)))
|
|
monkeypatch.setattr(mod, "_browser_disconnect", lambda cli: calls.append(("disconnect",)))
|
|
monkeypatch.setattr(mod, "_browser_status", lambda: calls.append(("status",)))
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
mod.CLICommandsMixin._handle_browser_command(object(), cmd)
|
|
return calls, buf.getvalue()
|
|
|
|
def test_connect_keeps_url_case_and_defaults_to_status(self, monkeypatch):
|
|
from hermes_cli.browser_connect import DEFAULT_BROWSER_CDP_URL
|
|
|
|
assert self._run("/browser CONNECT ws://127.0.0.1:9222/devtools/browser/AbC", monkeypatch)[0] == [
|
|
("connect", "ws://127.0.0.1:9222/devtools/browser/AbC")]
|
|
assert self._run("/browser connect", monkeypatch)[0] == [("connect", DEFAULT_BROWSER_CDP_URL)]
|
|
assert self._run("/browser", monkeypatch)[0] == [("status",)]
|
|
|
|
def test_unknown_subcommand_prints_usage_only(self, monkeypatch):
|
|
calls, out = self._run("/browser frobnicate", monkeypatch)
|
|
assert calls == []
|
|
assert out.strip()
|
|
|
|
|
|
class TestNativeScreenshots:
|
|
"""Screenshots printed by capture_screenshot() attach directly to the
|
|
model's context when it has native vision — no aux vision-LLM detour."""
|
|
|
|
def _shot(self, tmp_path):
|
|
shot = tmp_path / "shot.png"
|
|
shot.write_bytes(b"\x89PNG fake")
|
|
return str(shot)
|
|
|
|
def test_find_screenshot_returns_last_fresh_path(self, tmp_path):
|
|
a, b = self._shot(tmp_path), str(tmp_path / "b.png")
|
|
(tmp_path / "b.png").write_bytes(b"\x89PNG fake2")
|
|
out = f"step one saved {a}\nthen saved {b}\n"
|
|
assert bu_cli._find_screenshot(out, since=time.time() - 5) == b
|
|
|
|
def test_find_screenshot_rejects_stale_and_missing(self, tmp_path):
|
|
stale = self._shot(tmp_path)
|
|
os.utime(stale, (time.time() - 900, time.time() - 900))
|
|
out = f"{stale}\n/nonexistent/dir/x.png\n"
|
|
assert bu_cli._find_screenshot(out, since=time.time()) is None
|
|
|
|
def test_vision_model_gets_multimodal_envelope(self, tmp_path, monkeypatch):
|
|
shot = self._shot(tmp_path)
|
|
cli = _fake_cli(tmp_path, f'cat > /dev/null\necho "{shot}"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
monkeypatch.setattr(
|
|
"tools.vision_tools._should_use_native_vision_fast_path", lambda: True
|
|
)
|
|
monkeypatch.setattr(
|
|
"tools.vision_tools._resize_image_for_vision",
|
|
lambda p, **kw: "data:image/png;base64,QUJD",
|
|
)
|
|
result = bu_cli.browser_exec("print(capture_screenshot())")
|
|
assert isinstance(result, dict) and result["_multimodal"] is True
|
|
kinds = [part["type"] for part in result["content"]]
|
|
assert kinds == ["text", "image_url"]
|
|
assert result["meta"]["screenshot_path"] == shot
|
|
assert shot in result["text_summary"]
|
|
|
|
def test_text_only_model_gets_plain_result_with_path(self, tmp_path, monkeypatch):
|
|
shot = self._shot(tmp_path)
|
|
cli = _fake_cli(tmp_path, f'cat > /dev/null\necho "{shot}"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
monkeypatch.setattr(
|
|
"tools.vision_tools._should_use_native_vision_fast_path", lambda: False
|
|
)
|
|
result = json.loads(bu_cli.browser_exec("print(capture_screenshot())"))
|
|
assert result["screenshot_path"] == shot
|
|
|
|
def test_no_screenshot_keeps_string_result(self, tmp_path, monkeypatch):
|
|
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "no images here"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print(1)"))
|
|
assert "screenshot_path" not in result
|
|
|
|
|
|
class TestStepLabels:
|
|
"""browser_exec code leads with a `# …` comment (per the tool
|
|
description); the TUI surfaces it as the step label and keeps the code
|
|
collapsed behind display.tool_preview_length."""
|
|
|
|
_CODE = "# Searching Amazon for paper towels\nnew_tab('https://amazon.com')\nwait_for_load()"
|
|
|
|
def test_leading_comment_becomes_step_label(self):
|
|
from agent.display import _browser_exec_step_label
|
|
|
|
assert _browser_exec_step_label({"code": self._CODE}) == "Searching Amazon for paper towels"
|
|
|
|
def test_no_comment_returns_none(self):
|
|
from agent.display import _browser_exec_step_label
|
|
|
|
assert _browser_exec_step_label({"code": "new_tab('x')"}) is None
|
|
assert _browser_exec_step_label({"code": ""}) is None
|
|
assert _browser_exec_step_label({"code": "# "}) is None
|
|
|
|
def test_label_hard_capped_regardless_of_global_setting(self):
|
|
from agent.display import _browser_exec_step_label
|
|
|
|
long = "# " + "x" * 200
|
|
label = _browser_exec_step_label({"code": long})
|
|
assert len(label) <= 80 and label.endswith("…")
|
|
|
|
def test_preview_prefers_comment_over_code(self):
|
|
from agent.display import build_tool_preview
|
|
|
|
assert build_tool_preview("browser_exec", {"code": self._CODE}) == (
|
|
"Searching Amazon for paper towels"
|
|
)
|
|
assert "new_tab" in build_tool_preview("browser_exec", {"code": "new_tab('x')"})
|
|
|
|
def test_progress_line_shows_label(self):
|
|
from agent.display import get_cute_tool_message
|
|
|
|
line = get_cute_tool_message("browser_exec", {"code": self._CODE}, 1.2)
|
|
assert "Searching Amazon for paper towels" in line
|
|
assert "new_tab" not in line
|
|
|
|
|
|
|
|
class TestHeaderVariants:
|
|
|
|
def test_text_only_header_teaches_text_workflow(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"tools.vision_tools._should_use_native_vision_fast_path", lambda: False
|
|
)
|
|
monkeypatch.setattr(
|
|
"tools.browser_lightpanda.lightpanda_engine_status", lambda: (False, "")
|
|
)
|
|
assert bu_cli._description_header() == bu_cli._HEADER_BASE + bu_cli._HEADER_TEXT_ONLY
|
|
|
|
def test_non_native_header_never_calls_the_model_blind(self, monkeypatch):
|
|
"""The gate measures whether screenshots ride back in THIS tool's results, not whether the
|
|
model sees (#131568: "your model cannot view images" made a model skip a user image). The
|
|
header names no other toolset's tool; model_tools adds the vision_analyze pointer."""
|
|
monkeypatch.setattr("tools.vision_tools._should_use_native_vision_fast_path", lambda: False)
|
|
monkeypatch.setattr("tools.browser_lightpanda.lightpanda_engine_status", lambda: (False, ""))
|
|
for aux_route in (True, False):
|
|
monkeypatch.setattr("tools.vision_tools.check_vision_requirements", lambda: aux_route)
|
|
header = bu_cli._description_header().lower()
|
|
assert "cannot view images" not in header and "your model cannot" not in header
|
|
assert "described below" not in header # no screenshot section follows the header
|
|
assert "not attached to this tool's results" in header and "text-first" in header
|
|
assert "vision_analyze" not in header
|
|
|
|
|
|
class TestSkillTextDescription:
|
|
"""The schema description is fully pinned: header + _HELPERS_DIGEST.
|
|
|
|
The live ``browser-use skill`` fetch was removed after A/B benchmarking
|
|
showed the pinned digest matches the full skill dump on success rate
|
|
(36/36 vs 36/36, opus-4.8 + kimi-k3) — see tools/browser_use_cli.py.
|
|
"""
|
|
|
|
def test_description_is_pinned_header_plus_digest(self, monkeypatch):
|
|
# Even with a CLI present, the description must NOT shell out.
|
|
monkeypatch.setattr(
|
|
bu_cli, "_find_cli",
|
|
lambda: (_ for _ in ()).throw(AssertionError("schema must not invoke the CLI")),
|
|
)
|
|
overrides = bu_cli._dynamic_schema_overrides()
|
|
assert overrides["description"].startswith(bu_cli._HEADER_BASE)
|
|
assert overrides["description"].endswith(bu_cli._HELPERS_DIGEST)
|
|
|
|
|
|
|
|
|
|
class TestBrowserExec:
|
|
def test_missing_cli_returns_install_hint(self, monkeypatch):
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
|
|
result = json.loads(bu_cli.browser_exec("print(page_info())"))
|
|
assert result["error"]
|
|
|
|
def test_empty_code_rejected(self):
|
|
result = json.loads(bu_cli.browser_exec(" "))
|
|
assert "error" in result
|
|
|
|
def test_code_piped_on_stdin(self, tmp_path, monkeypatch):
|
|
cli = _fake_cli(tmp_path, 'code=$(cat)\necho "got:$code"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec('print("hi")'))
|
|
assert result["success"] is True
|
|
assert result["exit_code"] == 0
|
|
assert 'got:print("hi")' in result["output"]
|
|
assert "session" not in result
|
|
|
|
def test_session_sets_bu_name(self, tmp_path, monkeypatch):
|
|
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "bu:$BU_NAME"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print(1)", session="r7k2"))
|
|
assert "bu:r7k2" in result["output"]
|
|
assert result["session"] == "r7k2"
|
|
|
|
def test_invalid_session_name_rejected(self, monkeypatch, tmp_path):
|
|
cli = _fake_cli(tmp_path, "cat > /dev/null\n")
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print(1)", session="bad name!"))
|
|
assert "error" in result
|
|
assert "session" in result["error"].lower()
|
|
|
|
def test_nonzero_exit_reports_failure_and_stderr(self, tmp_path, monkeypatch):
|
|
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "boom" >&2\nexit 3\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print(1)"))
|
|
assert result["success"] is False
|
|
assert result["exit_code"] == 3
|
|
assert "boom" in result["stderr"]
|
|
|
|
|
|
|
|
|
|
|
|
class TestLightpandaBackendResolution:
|
|
"""browser.engine: lightpanda — the session layer spawns ``lightpanda serve`` through the same
|
|
get_session_info machinery and browser_exec exports its endpoint, but only when nothing with
|
|
higher precedence (BU_CDP_* env, a CDP override, a cloud provider) claimed the session."""
|
|
|
|
def _setup(self, monkeypatch, *, engine=True, info=None, boom=None):
|
|
|
|
seen = []
|
|
|
|
def fake_session_info(key):
|
|
seen.append(key)
|
|
if boom:
|
|
raise boom
|
|
return info if info is not None else {"cdp_url": "http://127.0.0.1:43111"}
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: None)
|
|
monkeypatch.setattr("tools.browser_lightpanda.using_lightpanda_engine", lambda: engine)
|
|
monkeypatch.setattr(bs, "get_session_info", fake_session_info)
|
|
return seen
|
|
|
|
def test_exports_bu_cdp_url_and_private_sentinel(self, monkeypatch):
|
|
seen = self._setup(monkeypatch)
|
|
env = {}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_URL"] == "http://127.0.0.1:43111"
|
|
assert env[bu_cli._PRIVATE_BROWSER_SENTINEL] == "1"
|
|
assert seen == ["t1"]
|
|
|
|
def test_named_session_keys_its_own_process(self, monkeypatch):
|
|
seen = self._setup(monkeypatch)
|
|
assert bu_cli._resolve_backend_cdp({}, "t1", session_name="r7k2") is None
|
|
assert seen == ["bu-named-r7k2"]
|
|
|
|
def test_default_key_without_task(self, monkeypatch):
|
|
seen = self._setup(monkeypatch)
|
|
assert bu_cli._resolve_backend_cdp({}, None) is None
|
|
assert seen == ["browser-exec-default"]
|
|
|
|
def test_launch_failure_returns_actionable_error(self, monkeypatch):
|
|
self._setup(monkeypatch, boom=RuntimeError("no lightpanda binary was found"))
|
|
err = bu_cli._resolve_backend_cdp({}, "t1")
|
|
assert err and "no lightpanda binary was found" in err
|
|
|
|
def test_missing_cdp_returns_error(self, monkeypatch):
|
|
self._setup(monkeypatch, info={"cdp_url": None})
|
|
err = bu_cli._resolve_backend_cdp({}, "t1")
|
|
assert err
|
|
|
|
def test_engine_auto_falls_through_to_packaged_chromium(self, monkeypatch):
|
|
seen = self._setup(monkeypatch, engine=False, info={
|
|
"cdp_url": "ws://127.0.0.1:47000/devtools/browser/t1", "features": {"local": True}})
|
|
env = {}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert seen == ["t1"] # one session-layer call; the engine is chosen inside it
|
|
assert env["BU_CDP_WS"].startswith("ws://")
|
|
|
|
def test_engine_names_the_failing_local_browser(self, monkeypatch):
|
|
self._setup(monkeypatch, engine=False, boom=RuntimeError("Chromium browser is missing."))
|
|
err = bu_cli._resolve_backend_cdp({}, "t1")
|
|
assert "local browser could not be started" in err and "Lightpanda" not in err
|
|
|
|
def test_bu_env_wins(self, monkeypatch):
|
|
seen = self._setup(monkeypatch)
|
|
env = {"BU_CDP_WS": "ws://operator:9222"}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "ws://operator:9222"
|
|
assert seen == []
|
|
|
|
def test_cdp_override_wins(self, monkeypatch):
|
|
|
|
seen = self._setup(monkeypatch)
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "http://127.0.0.1:9222")
|
|
env = {}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_URL"] == "http://127.0.0.1:9222"
|
|
assert seen == []
|
|
|
|
def test_cloud_provider_wins(self, monkeypatch):
|
|
|
|
seen = self._setup(monkeypatch, info={"cdp_url": "wss://cloud.example/x"})
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
env = {}
|
|
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
|
assert env["BU_CDP_WS"] == "wss://cloud.example/x"
|
|
assert seen == ["t1"] # provider path, same cache key
|
|
|
|
|
|
class TestLightpandaPreamble:
|
|
def test_lightpanda_session_skips_own_tab_preamble(self, tmp_path, monkeypatch):
|
|
"""A Lightpanda process is private to its session: no sibling daemon
|
|
to collide with, and Target.createTarget would fail anyway
|
|
(lightpanda-io/browser#1962)."""
|
|
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: None)
|
|
monkeypatch.setattr("tools.browser_lightpanda.using_lightpanda_engine", lambda: True)
|
|
monkeypatch.setattr(
|
|
bs, "get_session_info", lambda key: {"cdp_url": "http://127.0.0.1:43111"}
|
|
)
|
|
cli = _fake_cli(tmp_path, "cat\n")
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
result = json.loads(bu_cli.browser_exec("print('payload')", session="r7k2"))
|
|
assert result["success"] is True
|
|
assert "_hermes_ensure_own_tab" not in result["output"]
|
|
assert "print('payload')" in result["output"]
|
|
|
|
|
|
class TestLightpandaHeader:
|
|
def test_lightpanda_header_is_text_first_even_for_vision_models(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"tools.vision_tools._should_use_native_vision_fast_path", lambda: True
|
|
)
|
|
monkeypatch.setattr(
|
|
"tools.browser_lightpanda.lightpanda_engine_status", lambda: (True, "used")
|
|
)
|
|
header = bu_cli._description_header()
|
|
assert header.startswith(bu_cli._HEADER_BASE)
|
|
assert header.endswith(bu_cli._HEADER_LIGHTPANDA)
|
|
assert bu_cli._HEADER_VISION not in header
|
|
overrides = bu_cli._dynamic_schema_overrides()
|
|
assert overrides["description"].startswith(bu_cli._HEADER_BASE)
|
|
assert overrides["description"].endswith(bu_cli._HELPERS_DIGEST)
|
|
|
|
def test_shadowed_engine_keeps_default_header(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"tools.vision_tools._should_use_native_vision_fast_path", lambda: True
|
|
)
|
|
monkeypatch.setattr(
|
|
"tools.browser_lightpanda.lightpanda_engine_status", lambda: (False, "cloud")
|
|
)
|
|
assert bu_cli._description_header() == bu_cli._HEADER_BASE + bu_cli._HEADER_VISION
|
|
|
|
|
|
class TestLightpandaPickerRow:
|
|
def _rows(self):
|
|
from hermes_cli.tools_config import TOOL_CATEGORIES
|
|
|
|
return TOOL_CATEGORIES["browser"]["providers"]
|
|
|
|
def _row(self, name):
|
|
return next(r for r in self._rows() if r["name"] == name)
|
|
|
|
|
|
def test_selecting_lightpanda_writes_engine_and_local(self):
|
|
from hermes_cli.tools_config import _write_provider_config
|
|
|
|
config = {"browser": {"cloud_provider": "browserbase"}}
|
|
_write_provider_config(self._row("Lightpanda"), config, managed_feature=None)
|
|
assert config["browser"]["cloud_provider"] == "local"
|
|
assert config["browser"]["engine"] == "lightpanda"
|
|
|
|
def test_selecting_local_browser_resets_engine(self):
|
|
from hermes_cli.tools_config import _write_provider_config
|
|
|
|
config = {"browser": {"cloud_provider": "local", "engine": "lightpanda"}}
|
|
_write_provider_config(self._row("Local Browser"), config, managed_feature=None)
|
|
assert config["browser"]["engine"] == "auto"
|
|
|
|
def test_active_row_follows_engine(self):
|
|
from hermes_cli.tools_config import _is_provider_active
|
|
|
|
lp_row, local_row = self._row("Lightpanda"), self._row("Local Browser")
|
|
lp_cfg = {"browser": {"cloud_provider": "local", "engine": "lightpanda"}}
|
|
assert _is_provider_active(lp_row, lp_cfg) is True
|
|
assert _is_provider_active(local_row, lp_cfg) is False
|
|
default_cfg = {"browser": {"cloud_provider": "local"}}
|
|
assert _is_provider_active(lp_row, default_cfg) is False
|
|
assert _is_provider_active(local_row, default_cfg) is True
|
|
cloud_cfg = {"browser": {"cloud_provider": "browserbase", "engine": "lightpanda"}}
|
|
assert _is_provider_active(lp_row, cloud_cfg) is False
|
|
|
|
|
|
|
|
|
|
class TestTimeoutProcessGroupKill:
|
|
"""#106244: on timeout the whole CLI process group must die. A pipe-holding
|
|
grandchild (browser_harness daemon / Chrome helper) otherwise keeps communicate()
|
|
blocked forever, and the wedged call's activity heartbeat pins the session at
|
|
"now" in the sidebar indefinitely."""
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups")
|
|
def test_timeout_kills_grandchild_and_returns_promptly(self, tmp_path, monkeypatch):
|
|
"""A grandchild that outlives the direct child and holds the inherited stdout
|
|
pipe must not keep browser_exec blocked past the timeout (it wedged permanently
|
|
before the group kill)."""
|
|
monkeypatch.setattr("hermes_cli.config_effective.load_user_config_effective", dict)
|
|
pid_file = tmp_path / "grandchild.pid"
|
|
cli = _fake_cli(tmp_path, (
|
|
"cat > /dev/null\n"
|
|
"sleep 60 &\n"
|
|
"echo $! > \"" + str(pid_file) + "\"\n"
|
|
"sleep 60\n"
|
|
))
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
monkeypatch.setattr(bu_cli, "_MIN_TIMEOUT_S", 1)
|
|
|
|
start = time.time()
|
|
result = json.loads(bu_cli.browser_exec("print(1)", timeout_s=1))
|
|
elapsed = time.time() - start
|
|
|
|
assert "timed out" in result["error"]
|
|
# Pre-fix, this hangs until the 60s sleeps expire — and forever with a daemon child.
|
|
assert elapsed < 30
|
|
# The pipe-holding grandchild died with the group instead of leaking.
|
|
pid = int(pid_file.read_text(encoding="utf-8-sig").strip())
|
|
deadline = time.time() + 5
|
|
while time.time() < deadline and psutil.pid_exists(pid):
|
|
time.sleep(0.05)
|
|
assert not psutil.pid_exists(pid)
|
|
|
|
def test_post_kill_drain_is_bounded(self, monkeypatch):
|
|
"""If even the post-kill drain misses its deadline, give up instead of wedging."""
|
|
|
|
class _StuckProc:
|
|
pid = 424243
|
|
returncode = None
|
|
|
|
def communicate(self, input=None, timeout=None):
|
|
raise subprocess.TimeoutExpired("browser-use", timeout)
|
|
|
|
monkeypatch.setattr(bu_cli.subprocess, "Popen", lambda *a, **k: _StuckProc())
|
|
monkeypatch.setattr(bu_cli, "_kill_cli_process_group", lambda proc: None)
|
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
bu_cli._run_cli_killing_process_group(["x"], "code", {}, 5)
|
|
|
|
|
|
class TestExecHybridPrivateUrlRouting:
|
|
"""With a cloud provider configured and ``browser.auto_local_for_private_urls`` on, a browser_exec
|
|
call carrying a private URL runs on the hybrid LOCAL sidecar or is refused; it never reaches the
|
|
cloud browser (one CDP endpoint per call)."""
|
|
|
|
LAN = "http://192.168.0.1:9120/login"
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _cloud_with_hybrid(self, monkeypatch, tmp_path):
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override", lambda: "")
|
|
monkeypatch.setattr("tools.browser_session_cdp.get_cdp_override_raw", lambda: "")
|
|
monkeypatch.setattr(bs_cloud, "get_cloud_provider", lambda: object())
|
|
monkeypatch.setattr(bs_cloud, "auto_local_for_private_urls", lambda: True)
|
|
monkeypatch.setattr(bu_cli, "_exec_route_is_sidecar", {}, raising=False)
|
|
monkeypatch.setattr(bu_cli, "_driven_daemons", {})
|
|
self.cloud_keys, self.local_keys = [], []
|
|
|
|
def session_info(key): # one session layer: the ::local sidecar is a local browser
|
|
if bs.is_local_sidecar_key(key):
|
|
self.local_keys.append(key)
|
|
return {"cdp_url": f"ws://127.0.0.1:47000/devtools/browser/{key}", "features": {"local": True}}
|
|
self.cloud_keys.append(key)
|
|
return {"cdp_url": "wss://cloud.example/x"}
|
|
monkeypatch.setattr(bs, "get_session_info", session_info)
|
|
cli = _fake_cli(tmp_path, 'cat >/dev/null; printf "%s" "${BU_NAME:-default}"\n')
|
|
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
|
|
|
|
@staticmethod
|
|
def _dns(monkeypatch, first_ip, later_ip):
|
|
"""A rebinding host: ``first_ip`` for the first lookup, ``later_ip`` for every later one."""
|
|
lookups = []
|
|
|
|
def fake_getaddrinfo(host, *a, **kw):
|
|
lookups.append(host)
|
|
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (first_ip if len(lookups) == 1 else later_ip, 0))]
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
@pytest.mark.parametrize("case, code, refusal", [
|
|
("lan", f'new_tab("{LAN}")', None),
|
|
("mixed", f'new_tab("https://example.com/x")\nnew_tab("{LAN}")', None),
|
|
("rebind-private-then-public", 'new_tab("http://rebind.example/login")', None),
|
|
("imds", 'new_tab("http://169.254.169.254/latest/meta-data/")', "cloud metadata"),
|
|
("hybrid-off", f'new_tab("{LAN}")', "private or internal"),
|
|
("rebind-public-then-private", 'new_tab("http://flip.example/x")', "private or internal"),
|
|
])
|
|
def test_private_url_runs_on_local_sidecar_or_is_refused(self, monkeypatch, case, code, refusal):
|
|
if case.startswith("rebind-private"):
|
|
self._dns(monkeypatch, "192.168.1.5", "93.184.216.34")
|
|
elif case.startswith("rebind-public"):
|
|
self._dns(monkeypatch, "93.184.216.34", "192.168.1.5")
|
|
elif case == "hybrid-off":
|
|
monkeypatch.setattr(bs_cloud, "auto_local_for_private_urls", lambda: False)
|
|
|
|
result = json.loads(bu_cli.browser_exec(code, task_id="t"))
|
|
|
|
assert self.cloud_keys == [], "a call carrying a private URL reached the cloud provider"
|
|
if refusal:
|
|
assert refusal in result.get("error", ""), result
|
|
assert self.local_keys == []
|
|
else:
|
|
assert result["success"] is True, result
|
|
assert self.local_keys == ["t::local"]
|
|
assert result["output"] == "_local_default"
|
|
|
|
def test_sidecar_route_has_its_own_daemon_and_sticks_for_url_less_calls(self):
|
|
"""The harness daemon reads BU_CDP_* once at spawn, so the sidecar must not share the cloud
|
|
route's daemon; a follow-up with no URL literal keeps driving the page the last call opened."""
|
|
steps = [('new_tab("https://example.com/")', "default"),
|
|
(f'new_tab("{self.LAN}")', "_local_default"),
|
|
('print(js("document.title"))', "_local_default")]
|
|
|
|
daemons = [json.loads(bu_cli.browser_exec(code, task_id="t"))["output"] for code, _ in steps]
|
|
|
|
assert daemons == [want for _, want in steps]
|
|
assert self.cloud_keys == ["t"]
|
|
assert self.local_keys == ["t::local", "t::local"]
|