fix(browser): browser_exec local mode drives the packaged Chromium, not the user's Chrome
Symptom: with Browser Use mode (the default) and no cloud provider / CDP override, browser_exec left BU_CDP_* unset, so the browser-use harness ran its own local discovery — hunting for the user's INSTALLED Chrome on its default profile. That path needs the chrome://inspect remote-debugging toggle plus an "Allow remote debugging?" popup per run, is blocked outright on Chrome >=136, and on a headless host (or one without Chrome) fails with "chrome-not-running: no supported Chromium-family browser is running". The built-in browser_* tools never had this problem: they drive the Chromium Hermes installs, launched through agent-browser. Change: `_resolve_backend_cdp` step 4 becomes the local ENGINE — lightpanda when configured, otherwise `_resolve_managed_chromium_cdp`, which runs `agent-browser --session <key> get cdp-url` through the legacy `_run_browser_command` (Chromium preflight/auto-install, per-key session cache, inactivity reaper, atexit) and exports the returned ws:// endpoint as BU_CDP_WS. Each cache key (task, or bu-named-<session>) gets its own Chromium, so named sessions are private and skip the own-tab preamble. Running `get cdp-url` on every call also refreshes agent-browser's idle timer (it never sees the harness's direct CDP traffic) and follows a relaunch after the reaper closed the browser. Live: before, browser_exec on origin/main → exit 1 "chrome-not-running"; after → new_tab/page_info succeed on a cold start, warm reuse, post-reap relaunch, and a named session; a missing Chromium surfaces the install hint instead of the harness's Chrome hunt.
This commit is contained in:
@@ -32,6 +32,20 @@ def _clean_env(monkeypatch):
|
||||
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
|
||||
|
||||
|
||||
def _fake_cli(tmp_path, body):
|
||||
"""Write an executable fake browser-use CLI and return its path."""
|
||||
script = tmp_path / "browser-use"
|
||||
@@ -420,12 +434,29 @@ class TestBackendCdpResolution:
|
||||
assert bu_cli._resolve_backend_cdp(env, "t1") is None
|
||||
assert env["BU_CDP_WS"] == "wss://browser.example/cdp/abc"
|
||||
|
||||
def test_no_provider_leaves_env_untouched(self, monkeypatch):
|
||||
|
||||
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):
|
||||
@@ -511,13 +542,13 @@ class TestBackendCdpResolution:
|
||||
|
||||
|
||||
class TestOwnTabPreamble:
|
||||
"""Named sessions on SHARED browsers get the own-tab preamble prepended;
|
||||
private per-name browsers and unnamed sessions do not."""
|
||||
"""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):
|
||||
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: "")
|
||||
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(
|
||||
@@ -532,12 +563,18 @@ class TestOwnTabPreamble:
|
||||
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")
|
||||
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
|
||||
@@ -1142,12 +1179,12 @@ class TestLightpandaBackendResolution:
|
||||
err = bu_cli._resolve_backend_cdp({}, "t1")
|
||||
assert err and "no CDP endpoint" in err
|
||||
|
||||
def test_engine_auto_leaves_env_untouched(self, monkeypatch):
|
||||
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 env == {}
|
||||
assert seen == []
|
||||
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)
|
||||
|
||||
@@ -31,7 +31,7 @@ _SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
# (per-name provider / named BU cloud / Lightpanda). Popped before the subprocess launches — never exported.
|
||||
_PRIVATE_BROWSER_SENTINEL = "_HERMES_BU_PRIVATE_BROWSER"
|
||||
|
||||
# Prepended to the model's code for named sessions on SHARED browsers (local Chrome / CDP override): the
|
||||
# Prepended to the model's code for named sessions on SHARED browsers (a /browser connect CDP override): the
|
||||
# harness daemon attaches to the first existing page at startup, so two fresh named daemons can land on the
|
||||
# SAME tab. Steering each onto a tab it created prevents clobbering. Runs once per daemon (marker keyed by
|
||||
# BU_NAME + daemon pid).
|
||||
@@ -371,15 +371,49 @@ def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str
|
||||
return err
|
||||
|
||||
|
||||
def _resolve_managed_chromium_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]:
|
||||
"""Point the harness at Hermes' packaged Chromium, launched through agent-browser for this cache key —
|
||||
the same browser the built-in tools drive. Left alone, the harness discovers the user's INSTALLED
|
||||
Chrome on its default profile, which needs the chrome://inspect toggle + an Allow popup per run and
|
||||
is blocked outright on Chrome >=136; on a headless box it just reports ``chrome-not-running``.
|
||||
``get cdp-url`` runs through ``_run_browser_command`` (legacy cache, inactivity reaper, atexit, Chromium
|
||||
preflight/auto-install) on EVERY call: it launches the browser cold, follows a relaunch, and refreshes
|
||||
the agent-browser daemon's idle timer, which never sees the harness's direct CDP traffic."""
|
||||
try:
|
||||
from tools.browser_tool_session import _run_browser_command
|
||||
from tools.browser_tool import _get_open_command_timeout
|
||||
except Exception as e: # pragma: no cover — stubbed browser_tool in tests
|
||||
logger.debug("managed chromium resolution unavailable: %s", e)
|
||||
return None
|
||||
res = _run_browser_command(_backend_cache_key(task_id, session_name), "get", ["cdp-url"],
|
||||
timeout=_get_open_command_timeout(first_open=True))
|
||||
cdp = str(((res or {}).get("data") or {}).get("cdpUrl") or "") if (res or {}).get("success") else ""
|
||||
if not cdp:
|
||||
return (f"The local browser could not be started: {(res or {}).get('error') or 'agent-browser returned no CDP endpoint'} "
|
||||
"Run `hermes tools` → Browser Automation to (re)install Chromium, or switch backends.")
|
||||
_set_cdp_env(env, cdp)
|
||||
env[_PRIVATE_BROWSER_SENTINEL] = "1" # one Chromium per cache key: nothing to share a tab with
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_local_engine_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]:
|
||||
"""Local engine (no provider / override): ``browser.engine: lightpanda`` or the packaged Chromium."""
|
||||
err = _resolve_lightpanda_cdp(env, task_id, session_name)
|
||||
if err or _has_cdp_env(env):
|
||||
return err
|
||||
return _resolve_managed_chromium_cdp(env, task_id, session_name)
|
||||
|
||||
|
||||
def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]:
|
||||
"""Point the harness at the configured backend's CDP endpoint; error string on failure.
|
||||
|
||||
Precedence: (1) ``BU_CDP_WS``/``BU_CDP_URL`` already in env (operator override); (2) ``BROWSER_CDP_URL``
|
||||
env / ``browser.cdp_url`` (``/browser connect``); (3) a cloud provider via the legacy ``_get_session_info()``
|
||||
so browser_exec shares the SAME session machinery (per-task cache, expiry, reaper, atexit);
|
||||
(4) ``browser.engine: lightpanda``; (5) nothing → None: the harness attaches to local Chrome (or BU
|
||||
cloud via BU_AUTOSPAWN for legacy configs). ``session_name`` (BU_NAME) keys the provider cache so
|
||||
each name gets its OWN cloud browser — what makes named sessions concurrent-safe.
|
||||
(4) the local engine — ``browser.engine: lightpanda`` or Hermes' packaged Chromium via agent-browser
|
||||
(never the harness's own discovery of the user's installed Chrome); (5) BU direct-API configs → None:
|
||||
the CLI reaches BU cloud natively (BU_AUTOSPAWN). ``session_name`` (BU_NAME) keys the session cache so
|
||||
each name gets its OWN browser — what makes named sessions concurrent-safe.
|
||||
"""
|
||||
if _has_cdp_env(env):
|
||||
return None
|
||||
@@ -396,7 +430,7 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str =
|
||||
return None
|
||||
provider = _quiet(_get_cloud_provider, None, "Cloud provider lookup failed")
|
||||
if provider is None:
|
||||
return _resolve_lightpanda_cdp(env, task_id, session_name)
|
||||
return _resolve_local_engine_cdp(env, task_id, session_name)
|
||||
|
||||
# Browser Use direct-API configs: the CLI talks to BU cloud natively (BU_AUTOSPAWN / auth login) — the
|
||||
# legacy provider would create a second, redundant session. The Nous-gateway variant (use_gateway: true)
|
||||
@@ -509,7 +543,7 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT
|
||||
if route_err:
|
||||
return tool_error(route_err)
|
||||
|
||||
# SHARED browser (local Chrome / CDP override): pin each named session to its own tab (see
|
||||
# SHARED browser (/browser connect CDP override): pin each named session to its own tab (see
|
||||
# _OWN_TAB_PREAMBLE). Private per-name browsers skip this — nothing to collide with.
|
||||
private_browser = env.pop(_PRIVATE_BROWSER_SENTINEL, None) # always pop: never exported to the CLI
|
||||
if session and not private_browser:
|
||||
|
||||
@@ -75,9 +75,11 @@ Browser Use mode uses the [Browser Use CLI 3.0](https://github.com/browser-use/b
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
The mode is a **driver** that composes with your configured browser backend: it drives Hermes' own headless Chromium, 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.
|
||||
|
||||
**Concurrent sessions:** `browser_exec` accepts a `session=<name>` argument that isolates browser work per name on every backend. Each name gets its own harness daemon (its own IPC socket, log, and state), and on cloud backends its own browser — so parallel subagents or simultaneous chats no longer clobber a single shared connection. Omitting `session` uses the shared default daemon, which is fine for one-at-a-time browsing.
|
||||
**Local browsing uses the packaged Chromium, not your own Chrome.** With no cloud provider or `/browser connect` endpoint configured, Hermes launches the same Chromium that the built-in tools use (installed via `hermes tools` → Browser Automation, driven through agent-browser) and points the Browser Use CLI at it. Your installed Chrome is never touched, so there is no `chrome://inspect` remote-debugging toggle to enable and no "Allow remote debugging?" popup — and it works on headless hosts with no Chrome at all. The browser is shared with the built-in stack's lifecycle: it is closed after `browser.inactivity_timeout`, at exit, and by the orphan sweep. To drive a browser you're signed in to, use `/browser connect` or the [real-profile toggle](#real-profile-browsing-use-your-own-logins).
|
||||
|
||||
**Concurrent sessions:** `browser_exec` accepts a `session=<name>` argument that isolates browser work per name on every backend. Each name gets its own harness daemon (its own IPC socket, log, and state) and its own browser (a separate packaged Chromium locally, a separate cloud browser on cloud backends) — so parallel subagents or simultaneous chats no longer clobber a single shared connection. Omitting `session` uses the shared default daemon, which is fine for one-at-a-time browsing.
|
||||
|
||||
To opt out and force the built-in browser tools, use `/browser use off`, or:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user