feat(browser): make Browser Use mode the default browser backend

An unset browser.backend ("") now resolves to Browser Use mode whenever
the browser-use CLI is runnable (installed binary or uvx); otherwise the
built-in browser tools are kept so browsing never silently breaks.
Camofox setups always keep the built-in tools (no CDP surface), and
backend: off (including YAML 1.1 bare off -> False) forces the built-in
stack. hermes tools row highlighting follows the same effective-mode
resolution, and tests/tools/ pins CLI discovery off so host uvx installs
can't flip built-in-browser tests.
This commit is contained in:
Teknium 2026-08-10 12:15:01 -07:00
parent 37e46c774c
commit 8d8bc85dca
6 changed files with 143 additions and 29 deletions

View File

@ -404,10 +404,15 @@ DEFAULT_CONFIG = {
"browser": {
# Browser tool implementation.
# "" — built-in browser tools (browser_navigate, browser_click, …)
# "browser-use" — Browser Use mode: one browser_exec tool driving the
# Browser Use CLI 3.0 (local Chrome over CDP or Browser
# Use cloud browsers)
# "" — DEFAULT: Browser Use mode when the browser-use CLI
# (or uvx) is available; otherwise the built-in
# browser tools. Camofox setups always keep the
# built-in tools (no CDP surface).
# "browser-use" — force Browser Use mode: one browser_exec tool
# driving the Browser Use CLI 3.0 over any CDP
# backend (local Chrome, cloud browsers)
# "off" — force the built-in browser tools
# (browser_navigate, browser_click, …)
"backend": "",
"inactivity_timeout": 120,
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)

View File

@ -3666,17 +3666,35 @@ def _is_provider_active(
current = cfg_get(config, "browser", "cloud_provider")
return provider["browser_provider"] == current
if provider.get("browser_backend"):
if cfg_get(config, "browser", "backend") == provider["browser_backend"]:
backend = cfg_get(config, "browser", "backend")
if backend is False:
backend = "off" # YAML 1.1: unquoted `off` parses as boolean False
if backend == provider["browser_backend"]:
return True
# Legacy direct-API Browser Use cloud auto-routes to CLI
if backend:
return False # explicit other choice ("off", …) wins
if provider["browser_backend"] != "browser-use":
return False
# Backend unset: Browser Use mode is the default — the row is active
# whenever the effective mode resolves on (legacy direct-API cloud
# config, or CLI runnable and no Camofox).
browser_cfg = config.get("browser") if isinstance(config, dict) else None
try:
from tools.browser_use_cli import is_legacy_browser_use_cloud_config
browser_cfg = config.get("browser") if isinstance(config, dict) else None
return (
provider["browser_backend"] == "browser-use"
and is_legacy_browser_use_cloud_config(browser_cfg or {})
from tools.browser_use_cli import (
_find_cli,
is_legacy_browser_use_cloud_config,
)
if is_legacy_browser_use_cloud_config(browser_cfg or {}):
return True
try:
from tools.browser_camofox import is_camofox_mode
if is_camofox_mode():
return False
except Exception:
pass
return _find_cli() is not None
except Exception:
return False
if provider.get("web_backend"):

View File

@ -13,6 +13,28 @@ from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def _no_host_browser_use_cli():
"""Keep the host's browser-use/uvx install out of tests.
Browser Use mode is default-on when the CLI is runnable, so a developer
machine with uvx on PATH would silently flip every built-in-browser test
into CLI mode. Pin discovery to "not installed"; tests that exercise the
CLI path monkeypatch ``bu_cli._find_cli`` themselves.
"""
try:
import tools.browser_use_cli as bu_cli
except Exception:
yield
return
# Keep a handle to the real discovery function so TestFindCli (and any
# test that wants genuine PATH probing) can restore it explicitly.
if not hasattr(bu_cli, "_find_cli_unpatched"):
bu_cli._find_cli_unpatched = bu_cli._find_cli
with patch.object(bu_cli, "_find_cli", lambda: None):
yield
@pytest.fixture(autouse=True)
def _materialize_mcp_sdk_symbols():
"""Materialize the lazily-imported MCP SDK before each tools test.

View File

@ -38,8 +38,33 @@ def _fake_cli(tmp_path, body):
class TestModeDetection:
def test_off_by_default(self, monkeypatch):
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):
@ -56,11 +81,12 @@ class TestModeDetection:
)
assert bu_cli.is_browser_use_cli_mode() is False
def test_config_read_failure_fails_safe(self, monkeypatch):
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
@ -119,23 +145,26 @@ class TestToolSurfaceSwap:
class TestFindCli:
"""The tests/tools conftest pins _find_cli to None (host isolation);
exercise the real function via the preserved _find_cli_unpatched."""
def test_prefers_installed_binary(self, monkeypatch):
monkeypatch.setattr(
bu_cli.shutil, "which",
lambda name: "/usr/local/bin/browser-use" if name == "browser-use" else "/usr/local/bin/uvx",
)
assert bu_cli._find_cli() == ["/usr/local/bin/browser-use"]
assert bu_cli._find_cli_unpatched() == ["/usr/local/bin/browser-use"]
def test_falls_back_to_uvx(self, monkeypatch):
monkeypatch.setattr(
bu_cli.shutil, "which",
lambda name: "/usr/local/bin/uvx" if name == "uvx" else None,
)
assert bu_cli._find_cli() == ["/usr/local/bin/uvx", "browser-use"]
assert bu_cli._find_cli_unpatched() == ["/usr/local/bin/uvx", "browser-use"]
def test_none_when_neither_available(self, monkeypatch):
monkeypatch.setattr(bu_cli.shutil, "which", lambda name: None)
assert bu_cli._find_cli() is None
assert bu_cli._find_cli_unpatched() is None
class TestLegacyCloudMigration:
@ -156,10 +185,12 @@ class TestLegacyCloudMigration:
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):
@ -201,6 +232,7 @@ class TestLegacyCloudMigration:
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):
@ -209,6 +241,7 @@ class TestLegacyCloudMigration:
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):
@ -222,7 +255,9 @@ class TestLegacyCloudMigration:
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
def test_migrated_config_gets_bu_autospawn(self, tmp_path, monkeypatch):
@ -388,7 +423,7 @@ class TestProviderPickerIntegration:
assert config["browser"]["backend"] == "browser-use"
assert config["browser"]["cloud_provider"] == "local"
def test_provider_row_stays_active_alongside_cli_mode(self):
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"))
@ -401,9 +436,20 @@ class TestProviderPickerIntegration:
# driver attaches to.
assert _is_provider_active(local_row, cli_config) is True
local_config = {"browser": {"cloud_provider": "local"}}
assert _is_provider_active(cli_row, local_config) is False
assert _is_provider_active(local_row, local_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:

View File

@ -68,8 +68,18 @@ def _read_browser_cfg() -> dict:
def get_browser_backend() -> str:
"""Return the configured browser backend key ("" = legacy stack)."""
return str(_read_browser_cfg().get("backend") or "").strip().lower()
"""Return the configured browser backend key ("" = unset → default).
YAML 1.1 parses an unquoted ``off`` as boolean False — a hand-edited
``backend: off`` must mean BACKEND_DISABLED, not "unset". (True has no
sensible backend meaning; normalize it to unset.)
"""
raw = _read_browser_cfg().get("backend")
if raw is False:
return BACKEND_DISABLED
if raw is True:
return ""
return str(raw or "").strip().lower()
def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool:
@ -98,6 +108,11 @@ def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool:
def is_browser_use_cli_mode() -> bool:
"""True when the Browser Use CLI replaces the built-in browser stack.
Browser Use mode is the DEFAULT: an unset ``browser.backend`` ("") enables
it whenever the browser-use CLI is runnable (installed binary or uvx).
Set ``browser.backend: off`` (or ``/browser use off``) for the built-in
browser_* tools.
Camofox always falls back to the built-in tools regardless of
``browser.backend`` — it is Firefox-based with a custom HTTP API and no
CDP surface, so the CDP-only browser-use harness cannot drive it.
@ -112,7 +127,11 @@ def is_browser_use_cli_mode() -> bool:
backend = get_browser_backend()
if backend:
return backend == _BACKEND_KEY
return is_legacy_browser_use_cloud_config(_read_browser_cfg())
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
return True
# Default (backend unset): Browser Use mode when the CLI can run at all;
# otherwise keep the built-in tools so browsing never silently breaks.
return _find_cli() is not None
def _find_cli() -> Optional[List[str]]:

View File

@ -61,20 +61,24 @@ BROWSER_USE_API_KEY=***
Get your API key at [browser-use.com](https://browser-use.com).
### Browser Use mode
### Browser Use mode (default)
Browser Use mode uses the [Browser Use CLI 3.0](https://github.com/browser-use/browser-use) — a new browser harness that is state-of-the-art at web tasks — instead of the default browser tools. The agent writes and executes Python in the browser to click, type, drag, scrape, and interact with webpages.
Browser Use mode uses the [Browser Use CLI 3.0](https://github.com/browser-use/browser-use) — a new browser harness that is state-of-the-art at web tasks — instead of the built-in browser tools. The agent writes and executes Python in the browser to click, type, drag, scrape, and interact with webpages.
The mode is a **driver** that composes with your configured browser backend: it drives your local Chrome, a Nous-subscription cloud browser, Browserbase, Firecrawl, or Browser Use cloud browsers — whichever browser source is selected in `hermes tools` → Browser Automation. The one exception is Camofox, which has no CDP endpoint for the harness to attach to; Camofox setups automatically keep the built-in browser tools even when Browser Use mode is enabled.
**This is the default browser mode**: when `browser.backend` is unset and the `browser-use` CLI is runnable (installed, or available through `uvx`), the agent gets the single `browser_exec` tool. If the CLI can't run, Hermes falls back to the built-in browser tools automatically.
Enable it with `hermes tools` → **Browser Automation → Browser Use** (free · local · cloud), or directly:
The mode is a **driver** that composes with your configured browser backend: it drives your local Chrome, a Nous-subscription cloud browser, Browserbase, Firecrawl, or Browser Use cloud browsers — whichever browser source is selected in `hermes tools` → Browser Automation. The one exception is Camofox, which has no CDP endpoint for the harness to attach to; Camofox setups automatically keep the built-in browser tools.
To opt out and force the built-in browser tools, use `/browser use off`, or:
```yaml
# Add to ~/.hermes/config.yaml
browser:
backend: "browser-use"
backend: "off"
```
(`backend: "browser-use"` remains valid to force the mode explicitly.)
Browser Use's own cloud browsers need `browser-use auth login` or `BROWSER_USE_API_KEY`; other browser sources use their existing credentials unchanged.
:::note