Files
hermes-agent/tests/tools/test_browser_use_cli.py
ethernet 80273b4507 refactor(pm): route Python tool installs through PM
Use PM-selected interpreters and tool entrypoints for Browser Use, Hindsight and Python language servers. Sync the declared Google Chat extras instead of changing the active environment with pip.

Verified the affected 11-file Nix test subset: 359 passed, 7 skipped. The full suite and real third-party package installation were not run.
2026-09-11 18:05:43 -04:00

1296 lines
59 KiB
Python

"""Tests for the Browser Use CLI 3.0 backend (tools/browser_use_cli.py).
Covers the three seams the integration relies on:
* Mode detection — ``browser.backend: browser-use`` in config (set via the
``hermes tools`` picker); off by default.
* Tool-surface swap — when the mode is on, ``check_browser_requirements``
returns False so every legacy ``browser_*`` tool (including
browser_cdp/browser_dialog, whose check_fns funnel through it) is hidden,
and ``browser_exec`` is advertised instead.
* ``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 stat
import shutil
import subprocess
import sys
import time
import pytest
import tools.browser_use_cli as bu_cli
from tools import browser_tool_install as bt_install
from tools import browser_tool_cloud as bt_cloud
from tools import browser_tool_session as bt_session
@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)
yield
@pytest.fixture(autouse=True)
def _fake_managed_chromium(monkeypatch):
"""The local-engine route asks agent-browser for the packaged Chromium's CDP url; never launch
a real browser from a unit test. Records every ``get cdp-url`` session key in ``.calls``."""
calls = []
def fake_run(task_id, command, args=None, timeout=None, _engine_override=None):
calls.append((task_id, command, tuple(args or [])))
return {"success": True, "data": {"cdpUrl": f"ws://127.0.0.1:47000/devtools/browser/{task_id}"}}
monkeypatch.setattr(bt_session, "_run_browser_command", fake_run)
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))
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)
if os.name == "nt":
wrapper = script.with_suffix(".cmd")
wrapper.write_text(f'@"{shutil.which("bash")}" "{script.as_posix()}" %*\n')
return str(wrapper)
return str(script)
class TestModeDetection:
def test_default_on_when_cli_available(self, monkeypatch):
"""Backend unset: Browser Use mode is the default when the CLI runs."""
monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {})
monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["/usr/bin/browser-use"])
assert bu_cli.is_browser_use_cli_mode() is True
def test_default_off_when_cli_unavailable(self, monkeypatch):
"""Backend unset + no runnable CLI: keep the built-in browser tools."""
monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {})
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.is_browser_use_cli_mode() is False
def test_explicit_off_wins_over_default(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"backend": bu_cli.BACKEND_DISABLED}},
)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["/usr/bin/browser-use"])
assert bu_cli.is_browser_use_cli_mode() is False
def test_yaml_bool_off_means_disabled(self, monkeypatch):
"""YAML 1.1 parses unquoted `off` as False — must mean disabled."""
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"backend": False}},
)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["/usr/bin/browser-use"])
assert bu_cli.is_browser_use_cli_mode() is False
def test_config_opt_in(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"backend": "browser-use"}},
)
assert bu_cli.is_browser_use_cli_mode() is True
def test_other_backend_value_is_not_cli_mode(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"backend": "something-else"}},
)
assert bu_cli.is_browser_use_cli_mode() is False
def test_config_read_failure_uses_default(self, monkeypatch):
def boom():
raise RuntimeError("config unreadable")
monkeypatch.setattr("hermes_cli.config.read_raw_config", boom)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.is_browser_use_cli_mode() is False
class TestSubprocessEnvironment:
def test_browser_use_telemetry_defaults_off(self, monkeypatch):
import sys
from types import ModuleType
browser_tool = ModuleType("tools.browser_tool")
browser_tool._build_browser_env = lambda: {}
monkeypatch.setitem(sys.modules, "tools.browser_tool", browser_tool)
env = bu_cli._base_subprocess_env()
assert env["ANONYMIZED_TELEMETRY"] == "false"
def test_subprocess_env_strips_parent_python_import_paths(self, monkeypatch):
"""#83427/#84841/#86006/#86104: the browser-use CLI runs under its
own Python — inherited PYTHONPATH/PYTHONHOME pointing at Hermes's
venv make it import wrong-ABI C-extensions (pydantic_core) and
crash. Both must be stripped; unrelated vars survive."""
import sys
from types import ModuleType
browser_tool = ModuleType("tools.browser_tool")
browser_tool._build_browser_env = lambda: {
"PYTHONPATH": "/hermes:/hermes/venv/lib/site-packages",
"PYTHONHOME": "/hermes/venv",
"KEEP_ME": "yes",
}
monkeypatch.setitem(sys.modules, "tools.browser_tool", browser_tool)
env = bu_cli._base_subprocess_env()
assert "PYTHONPATH" not in env
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."""
import sys
from types import ModuleType
browser_tool = ModuleType("tools.browser_tool")
browser_tool._build_browser_env = lambda: {
"PATH": os.pathsep.join(
["/home/u/.nvm/versions/node/v24.18.0/bin"] * 7
),
}
monkeypatch.setitem(sys.modules, "tools.browser_tool", browser_tool)
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)
@pytest.mark.skipif(os.name == "nt", reason="POSIX PATH-floor semantics")
def test_floor_survives_missing_sibling_helper(self, monkeypatch):
"""If browser_tool stops exporting _merge_browser_path, the floor
degrades to appending FHS bin dirs instead of vanishing."""
import sys
from types import ModuleType
browser_tool = ModuleType("tools.browser_tool")
browser_tool._build_browser_env = lambda: {
"PATH": "/home/u/.nvm/versions/node/v24.18.0/bin"
}
monkeypatch.setitem(sys.modules, "tools.browser_tool", browser_tool)
env = bu_cli._base_subprocess_env()
parts = env["PATH"].split(os.pathsep)
assert "/usr/bin" in parts
assert "/home/u/.nvm/versions/node/v24.18.0/bin" in parts
class TestToolSurfaceSwap:
def test_legacy_browser_tools_hidden_in_cli_mode(self, monkeypatch):
import tools.browser_tool as browser_tool
monkeypatch.setattr(browser_tool, "_is_browser_use_cli_mode", lambda: True)
assert bt_install.check_browser_requirements() is False
assert bt_install.check_browser_vision_requirements() is False
def test_browser_exec_registered_with_mode_check(self):
from tools.registry import registry
entry = registry.get_entry("browser_exec")
assert entry is not None
assert entry.check_fn is bu_cli.is_browser_use_cli_mode
assert entry.toolset == "browser-use"
def test_browser_exec_in_browser_toolsets(self):
from toolsets import TOOLSETS, _HERMES_CORE_TOOLS
assert "browser_exec" in _HERMES_CORE_TOOLS
assert "browser_exec" in TOOLSETS["browser"]["tools"]
assert "browser_exec" in TOOLSETS["coding"]["tools"]
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)."""
monkeypatch.setattr(bu_cli, "is_browser_use_cli_mode", lambda: True)
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):
monkeypatch.setattr(bu_cli, "is_browser_use_cli_mode", lambda: True)
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.read_raw_config", lambda: {"browser": {"backend": "browser-use"}})
cli = _fake_cli(tmp_path, 'cat > /dev/null\necho ok\n')
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
monkeypatch.setattr("tools.browser_tool_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 TestFindCli:
def test_uses_only_pm_selected_tool(self, monkeypatch, tmp_path):
import pm
selected = tmp_path / "pm-generation" / "browser-use"
monkeypatch.setattr(pm, "python_tool", lambda name, executable: selected)
monkeypatch.setattr(shutil, "which", lambda *a, **kw: "/unmanaged/browser-use")
assert bu_cli._find_cli_unpatched() == [str(selected)]
def test_missing_managed_tool_does_not_use_path(self, monkeypatch):
import pm
monkeypatch.setattr(pm, "python_tool", lambda *a, **kw: None)
monkeypatch.setattr(shutil, "which", lambda *a, **kw: "/unmanaged/browser-use")
assert bu_cli._find_cli_unpatched() is None
class TestLegacyCloudMigration:
"""Pre-CLI direct-API Browser Use cloud configs (cloud_provider:
"browser-use" + BROWSER_USE_API_KEY) auto-route to the CLI backend;
Nous-gateway users stay on the legacy provider path."""
_LEGACY = {"browser": {"cloud_provider": "browser-use"}}
def test_direct_api_config_migrates(self, monkeypatch):
monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: self._LEGACY)
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
assert bu_cli.is_browser_use_cli_mode() is True
def test_gateway_config_stays_on_legacy_path(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"cloud_provider": "browser-use", "use_gateway": True}},
)
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.is_browser_use_cli_mode() is False
def test_no_api_key_stays_on_legacy_path(self, monkeypatch):
monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: self._LEGACY)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.is_browser_use_cli_mode() is False
def test_camofox_user_does_not_migrate(self, monkeypatch):
"""A Camofox user (env-var selected, cloud_provider unset) with a
stray BROWSER_USE_API_KEY keeps Camofox — no silent mode flip."""
monkeypatch.setattr(
"hermes_cli.config.read_raw_config", lambda: {"browser": {}}
)
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
import tools.browser_camofox as camofox
monkeypatch.setattr(camofox, "is_camofox_mode", lambda: True)
assert bu_cli.is_browser_use_cli_mode() is False
def test_camofox_overrides_explicit_backend(self, monkeypatch):
"""Even with browser.backend: browser-use, an active Camofox setup
falls back to the built-in tools (no CDP surface to drive)."""
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"backend": "browser-use"}},
)
import tools.browser_camofox as camofox
monkeypatch.setattr(camofox, "is_camofox_mode", lambda: True)
assert bu_cli.is_browser_use_cli_mode() is False
def test_explicit_other_backend_wins(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"cloud_provider": "browser-use", "backend": "something-else"}},
)
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
assert bu_cli.is_browser_use_cli_mode() is False
def test_other_cloud_provider_does_not_migrate(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"cloud_provider": "browserbase"}},
)
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.is_browser_use_cli_mode() is False
def test_explicit_local_does_not_migrate(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"cloud_provider": "local"}},
)
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.is_browser_use_cli_mode() 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 now means Browser Use mode."""
monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {})
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_browser_use_cli_mode() is True
def test_auto_detect_without_key_does_not_migrate(self, monkeypatch):
"""No key, no CLI: nothing to migrate and no default flip."""
monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {})
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.is_browser_use_cli_mode() is False
@pytest.mark.platforms("linux")
def test_migrated_config_gets_bu_autospawn(self, tmp_path, monkeypatch):
monkeypatch.setattr("hermes_cli.config.read_raw_config", 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"]
@pytest.mark.platforms("linux")
def test_explicit_backend_does_not_set_bu_autospawn(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"backend": "browser-use"}},
)
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"]
def test_picker_highlights_cli_row_for_migrated_config(self, monkeypatch):
from hermes_cli.tools_config import TOOL_CATEGORIES, _is_provider_active
cli_row = next(
r for r in TOOL_CATEGORIES["browser"]["providers"] if r.get("browser_backend")
)
monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-key")
assert _is_provider_active(cli_row, dict(self._LEGACY)) is True
monkeypatch.delenv("BROWSER_USE_API_KEY")
assert _is_provider_active(cli_row, dict(self._LEGACY)) is False
class TestBackendCdpResolution:
"""browser_exec routes through the configured browser backend by reusing
the legacy stack's provider session machinery (_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_tool_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_tool_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):
import tools.browser_tool as bt
monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: object())
monkeypatch.setattr(
bt_session, "_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 agent-browser-launched Chromium (same browser the built-in
tools use) — 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_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_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", "get", ("cdp-url",))]
env = self._env()
assert bu_cli._resolve_backend_cdp(env, "t1", session_name="r7k2") is None
assert _fake_managed_chromium[-1][0] == "bu-named-r7k2" # named session → its own Chromium
def test_packaged_chromium_launch_failure_is_an_error(self, monkeypatch):
monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: None)
monkeypatch.setattr(bt_session, "_run_browser_command",
lambda *a, **k: {"success": False, "error": "Chromium browser not installed"})
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_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: object())
monkeypatch.setattr(bt_session, "_get_session_info", boom)
err = bu_cli._resolve_backend_cdp(self._env(), "t1")
assert err and "api down" in err
def test_provider_without_cdp_returns_error(self, monkeypatch):
monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: object())
monkeypatch.setattr(bt_session, "_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
@pytest.mark.platforms("linux")
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_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: object())
monkeypatch.setattr(bt_session, "_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."""
import tools.browser_tool as bt
seen = []
monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: object())
monkeypatch.setattr(
bt_session, "_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."""
import tools.browser_tool as bt
class _BUProvider:
name = "browser-use"
monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: _BUProvider())
monkeypatch.setattr(
bt_session, "_get_session_info",
lambda key: (_ for _ in ()).throw(AssertionError("must skip provider")),
)
monkeypatch.setattr(bu_cli, "_read_browser_cfg", lambda: {"cloud_provider": "browser-use"})
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
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=""):
import tools.browser_tool as bt
monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: shared_cdp)
if provider:
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: object())
monkeypatch.setattr(
bt_session, "_get_session_info",
lambda key: {"cdp_url": "wss://browser.example/cdp/" + key},
)
else:
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: None)
# fake CLI echoes stdin back so we can inspect what code was sent
monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["test-browser-use"])
monkeypatch.setattr(bu_cli, "_run_cli_killing_process_group", lambda cmd, code, env, timeout:
subprocess.CompletedProcess(cmd, 0, code, ""))
return json.loads(bu_cli.browser_exec("print('payload')", session=session))
@pytest.mark.platforms("linux")
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"]
@pytest.mark.platforms("linux")
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"]
@pytest.mark.platforms("linux")
def test_sentinel_never_reaches_subprocess_env(self, tmp_path, monkeypatch):
import tools.browser_tool as bt
monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: object())
monkeypatch.setattr(
bt_session, "_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 row (browser_backend
marker) must enter/leave CLI mode cleanly and highlight correctly."""
def _rows(self):
from hermes_cli.tools_config import TOOL_CATEGORIES
return TOOL_CATEGORIES["browser"]["providers"]
def test_picker_has_browser_use_cli_row(self):
row = next(r for r in self._rows() if r.get("browser_backend"))
assert row["browser_backend"] == "browser-use"
assert row["name"] == "Browser Use"
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))
def test_selecting_cli_row_writes_backend_and_keeps_cloud_provider(self):
from hermes_cli.tools_config import _write_provider_config
row = next(r for r in self._rows() if r.get("browser_backend"))
config = {"browser": {"cloud_provider": "browserbase"}}
assert row["name"] == "Browser Use"
_write_provider_config(row, config, managed_feature=None)
assert config["browser"]["backend"] == "browser-use"
assert config["browser"]["cloud_provider"] == "browserbase"
def test_selecting_provider_row_keeps_cli_mode(self):
"""Backend composes with the provider: switching browser source
(local/Browserbase/Firecrawl/gateway) keeps the driver choice."""
from hermes_cli.tools_config import _write_provider_config
local_row = next(
r for r in self._rows() if r.get("browser_provider") == "local"
)
config = {"browser": {"backend": "browser-use"}}
_write_provider_config(local_row, config, managed_feature=None)
assert config["browser"]["backend"] == "browser-use"
assert config["browser"]["cloud_provider"] == "local"
def test_provider_row_stays_active_alongside_cli_mode(self, monkeypatch):
from hermes_cli.tools_config import _is_provider_active
cli_row = next(r for r in self._rows() if r.get("browser_backend"))
local_row = next(
r for r in self._rows() if r.get("browser_provider") == "local"
)
cli_config = {"browser": {"cloud_provider": "local", "backend": "browser-use"}}
assert _is_provider_active(cli_row, cli_config) is True
# Provider row remains highlighted: it supplies the browser the CLI
# driver attaches to.
assert _is_provider_active(local_row, cli_config) is True
# Explicit off: the CLI row must not highlight even with the CLI
# installed (default-on only applies while backend is unset).
monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["/usr/bin/browser-use"])
off_config = {"browser": {"cloud_provider": "local", "backend": "off"}}
assert _is_provider_active(cli_row, off_config) is False
assert _is_provider_active(local_row, off_config) is True
# Backend unset: default-on — the CLI row highlights when the CLI
# is runnable, and not when it isn't.
default_config = {"browser": {"cloud_provider": "local"}}
assert _is_provider_active(cli_row, default_config) is True
assert _is_provider_active(local_row, default_config) is True
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert _is_provider_active(cli_row, default_config) is False
class TestBrowserUseSlashCommand:
"""/browser use [off] toggles browser.backend and resets the session,
mirroring the /tools enable/disable flow."""
class _Stub:
def __init__(self):
self.session_resets = 0
def new_session(self):
self.session_resets += 1
def _run(self, cmd, config, monkeypatch):
import hermes_cli.config as hc
from hermes_cli.cli_commands_mixin import CLICommandsMixin
saved = {}
monkeypatch.setattr(hc, "load_config", lambda: config)
monkeypatch.setattr(hc, "save_config", lambda c: saved.update(c))
stub = self._Stub()
CLICommandsMixin._handle_browser_command(stub, cmd)
return stub, saved
def test_use_enables_backend_and_resets_session(self, monkeypatch):
stub, saved = self._run("/browser use", {}, monkeypatch)
assert saved["browser"]["backend"] == "browser-use"
assert stub.session_resets == 1
def test_use_off_pins_backend_off(self, monkeypatch):
"""`off` must be written explicitly (BACKEND_DISABLED), not removed:
with the key merely deleted, is_legacy_browser_use_cloud_config()
would re-activate CLI mode on the next start for anyone with
BROWSER_USE_API_KEY set, so /browser use off wouldn't stick."""
config = {"browser": {"backend": "browser-use"}}
stub, saved = self._run("/browser use off", config, monkeypatch)
assert saved["browser"]["backend"] == bu_cli.BACKEND_DISABLED
assert stub.session_resets == 1
def test_use_bad_arg_prints_usage_without_writing(self, monkeypatch):
stub, saved = self._run("/browser use whatever", {}, monkeypatch)
assert saved == {}
assert stub.session_resets == 0
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
@pytest.mark.platforms("linux")
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"]
@pytest.mark.platforms("linux")
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
def test_header_instructs_leading_comment(self):
assert "one-line comment" in bu_cli._HEADER_BASE
assert "step label" in bu_cli._HEADER_BASE
class TestHeaderVariants:
def test_vision_header_forbids_vision_tool_detour(self, monkeypatch):
monkeypatch.setattr(
"tools.vision_tools._should_use_native_vision_fast_path", lambda: True
)
header = bu_cli._description_header()
assert header.startswith(bu_cli._HEADER_BASE)
assert "attached to your context automatically" in header
def test_text_only_header_teaches_text_workflow(self, monkeypatch):
monkeypatch.setattr(
"tools.vision_tools._should_use_native_vision_fast_path", lambda: False
)
header = bu_cli._description_header()
assert "cannot view images" in header
assert "page_info()" 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)
def test_digest_names_core_helpers(self):
for helper in ("new_tab(", "page_info()", "js(", "fill_input(",
"click_at_xy(", "capture_screenshot()", "cdp("):
assert helper in bu_cli._HELPERS_DIGEST
def test_static_fallback_carries_digest_and_install_hint(self):
desc = bu_cli.BROWSER_EXEC_SCHEMA["description"]
assert bu_cli._HELPERS_DIGEST in desc
assert "hermes tools" in desc
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 "hermes tools" in result["error"]
def test_empty_code_rejected(self):
result = json.loads(bu_cli.browser_exec(" "))
assert "error" in result
@pytest.mark.platforms("linux")
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
@pytest.mark.platforms("linux")
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()
@pytest.mark.platforms("linux")
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"]
@pytest.mark.platforms("linux")
def test_timeout_returns_actionable_error(self, tmp_path, monkeypatch):
cli = _fake_cli(tmp_path, "cat > /dev/null\nsleep 30\n")
monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli])
monkeypatch.setattr(bu_cli, "_MIN_TIMEOUT_S", 1)
result = json.loads(bu_cli.browser_exec("print(1)", timeout_s=1))
assert "timed out" in result["error"]
class TestInstallCli:
def test_installs_through_pm_even_with_an_unmanaged_binary(self, tmp_path, monkeypatch):
import pm
selected = tmp_path / "pm-generation" / "browser-use"
seen = {}
def install(name, requirements, executable, **kwargs):
seen.update(name=name, requirements=requirements, executable=executable, **kwargs)
return selected
monkeypatch.setattr(pm, "ensure_python_tool", install)
monkeypatch.setattr(shutil, "which", lambda *a, **kw: "/unmanaged/browser-use")
ok, message = bu_cli.install_cli(timeout_s=321)
assert ok, message
assert str(selected) in message
assert seen == dict(name="browser-use", requirements=bu_cli._CLI_REQUIREMENTS,
executable="browser-use", explicit=True, timeout=321)
def test_pm_install_failure_is_actionable(self, monkeypatch):
import pm
def fail(*args, **kwargs):
raise RuntimeError("network unavailable; retry hermes tools")
monkeypatch.setattr(pm, "ensure_python_tool", fail)
monkeypatch.setattr(shutil, "which", lambda *a, **kw: None)
ok, message = bu_cli.install_cli()
assert not ok
assert "network unavailable" in message
assert "hermes tools" in message
class TestDefaultDowngradeNotice:
def _isolate(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {})
def test_notice_when_default_and_cli_missing(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
notice = bu_cli.default_downgrade_notice()
assert notice is not None
assert "hermes tools" in notice
def test_rate_limited_within_24h(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.default_downgrade_notice() is not None
assert bu_cli.default_downgrade_notice() is None
def test_no_notice_when_cli_runnable(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["/usr/bin/browser-use"])
assert bu_cli.default_downgrade_notice() is None
def test_no_notice_on_explicit_backend(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setattr(
"hermes_cli.config.read_raw_config",
lambda: {"browser": {"backend": bu_cli.BACKEND_DISABLED}},
)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: None)
assert bu_cli.default_downgrade_notice() is None
class TestLightpandaBackendResolution:
"""browser.engine: lightpanda in Browser Use mode — Hermes spawns
``lightpanda serve`` through the same _get_session_info machinery and
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_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: None)
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: engine)
monkeypatch.setattr(bt_session, "_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
assert "browser.engine" 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 and "no CDP endpoint" in err
def test_engine_auto_falls_through_to_packaged_chromium(self, monkeypatch, _fake_managed_chromium):
seen = self._setup(monkeypatch, engine=False)
env = {}
assert bu_cli._resolve_backend_cdp(env, "t1") is None
assert seen == [] # no lightpanda process
assert env["BU_CDP_WS"].startswith("ws://") and _fake_managed_chromium[0][1:] == ("get", ("cdp-url",))
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_tool_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(bt_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)."""
import tools.browser_tool as bt
monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: "")
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: None)
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True)
monkeypatch.setattr(
bt_session, "_get_session_info", lambda key: {"cdp_url": "http://127.0.0.1:43111"}
)
monkeypatch.setattr(bu_cli, "_find_cli", lambda: ["test-browser-use"])
monkeypatch.setattr(bu_cli, "_run_cli_killing_process_group", lambda cmd, code, env, timeout:
subprocess.CompletedProcess(cmd, 0, code, ""))
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_tool_lightpanda_fallback.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 "goto_url(url)" in header
assert "attached to your context automatically" 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_tool_lightpanda_fallback.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_lightpanda_row_shape(self):
row = self._row("Lightpanda")
assert row["browser_provider"] == "local"
assert row["browser_engine"] == "lightpanda"
assert row["post_setup"] == "lightpanda"
assert row["env_vars"] == []
# Local Browser stays the default-highlighted first row.
assert self._rows()[0]["name"] == "Local Browser"
def test_selecting_lightpanda_writes_engine_and_local_keeps_backend(self):
from hermes_cli.tools_config import _write_provider_config
config = {"browser": {"backend": "browser-use", "cloud_provider": "browserbase"}}
_write_provider_config(self._row("Lightpanda"), config, managed_feature=None)
assert config["browser"]["cloud_provider"] == "local"
assert config["browser"]["engine"] == "lightpanda"
assert config["browser"]["backend"] == "browser-use"
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 TestLightpandaStatusLine:
def _status(self, monkeypatch, *, used, reason, binary="/opt/lightpanda"):
import contextlib
import io
from hermes_cli.cli_commands_mixin import CLICommandsMixin
monkeypatch.setattr(bu_cli, "is_browser_use_cli_mode", lambda: True)
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True)
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback.lightpanda_engine_status", lambda: (used, reason))
monkeypatch.setattr("tools.browser_lightpanda.find_lightpanda_binary", lambda: binary)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
CLICommandsMixin._handle_browser_command(object(), "/browser status")
return buf.getvalue()
def test_status_reports_lightpanda_in_use(self, monkeypatch):
out = self._status(monkeypatch, used=True, reason="Browser Use mode: Hermes spawns `lightpanda serve` per session")
assert "Engine: Lightpanda" in out
assert "spawns `lightpanda serve`" in out
assert "Binary: /opt/lightpanda" in out
def test_status_reports_missing_binary(self, monkeypatch):
out = self._status(monkeypatch, used=True, reason="x", binary=None)
assert "lightpanda binary not found" in out
def test_status_reports_shadowed_engine(self, monkeypatch):
out = self._status(monkeypatch, used=False, reason="cloud provider Browserbase is selected")
assert "NOT in use" in out
assert "Browserbase" in out
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.read_raw_config", lambda: {})
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])
start = time.time()
result = json.loads(bu_cli.browser_exec("print(1)", timeout_s=bu_cli._MIN_TIMEOUT_S))
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().strip())
time.sleep(0.5)
with pytest.raises(OSError):
os.kill(pid, 0)
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)