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:
Teknium
2026-09-04 08:30:32 -07:00
parent f1ccf436a2
commit 9e13ac2f01
3 changed files with 91 additions and 18 deletions
+47 -10
View File
@@ -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)
+40 -6
View File
@@ -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:
+4 -2
View File
@@ -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: