diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index adff0090dbdad..e1bda5ebe194c 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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.) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index c8e244b878649..d713364dcdf39 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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"): diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py index 11969ad7b0fd5..355e3bb9a5379 100644 --- a/tests/tools/conftest.py +++ b/tests/tools/conftest.py @@ -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. diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py index 9c9497e062575..b7bd2c675e209 100644 --- a/tests/tools/test_browser_use_cli.py +++ b/tests/tools/test_browser_use_cli.py @@ -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: diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 0da115f247772..a09e1d01a10a7 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -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]]: diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 8f1ec183a63f9..e56a363676c33 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -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