From fb8a4aef59e8dabb88bd948b91bfc2069f255124 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:51:57 -0700 Subject: [PATCH] fix(browser): named browser_exec sessions compose with every backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session= previously set BU_NAME and then skipped backend resolution entirely — the parameter was documented as cloud-only, so all local/CDP work funneled through the single default daemon and one IPC socket, and concurrent sessions (parallel subagents, simultaneous chats) clobbered each other's browser connection. Reported by @shantanugoel on X. Now a named session composes with whatever browser source is configured: - BU_NAME still namespaces the harness daemon (per-name IPC socket, log, pid — upstream already isolates these), for local Chrome and CDP. - The /browser connect CDP override is now exported for named sessions too; previously a named daemon ignored it and fell back to scanning local Chrome profiles. - On provider backends (Browserbase, Firecrawl, Nous gateway), the name keys its own provider browser via the shared _get_session_info cache (bu-named-), so each name gets its own cloud browser, the same name reuses one across calls and tasks, and unnamed calls keep the per-task key. - Direct-API Browser Use cloud configs keep the native named-daemon path (provider resolution would double-session and double-bill). Tool schema/description updated so models reach for session= for parallel work on any backend, not just cloud. E2E: two named sessions against a real headless Chrome (real browser-use CLI, BU_CDP_URL) ran concurrently, set distinct page state, and read it back intact; sabotage run confirms the new tests fail without the fix. --- tests/tools/test_browser_use_cli.py | 62 ++++++++++++++++++--- tools/browser_use_cli.py | 42 ++++++++++---- website/docs/user-guide/features/browser.md | 2 + 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py index 0f6a197be9c9..08aefe3ec9a8 100644 --- a/tests/tools/test_browser_use_cli.py +++ b/tests/tools/test_browser_use_cli.py @@ -395,20 +395,68 @@ def test_provider_without_cdp_returns_error(self, monkeypatch): err = bu_cli._resolve_backend_cdp(self._env(), "t1") assert err and "no" in err.lower() and "CDP" in err - def test_named_session_skips_backend_resolution(self, tmp_path, monkeypatch): - """session= (BU_NAME cloud browser) must not consume a backend - provider session.""" + def test_named_session_composes_with_provider_backend(self, tmp_path, monkeypatch): + """session= composes with a configured provider backend: the + name keys its OWN provider browser (bu-named-), so concurrent + named sessions never share one browser (#86894).""" import tools.browser_tool as bt - def fail(task_id): - raise AssertionError("backend resolution must be skipped") + seen = [] - monkeypatch.setattr(bt, "_get_session_info", fail) - cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "bu:$BU_NAME"\n') + def fake_session_info(key): + seen.append(key) + return {"cdp_url": "wss://browser.example/cdp/" + key} + + monkeypatch.setattr(bt, "_get_cdp_override", lambda: "") + monkeypatch.setattr(bt, "_get_cloud_provider", lambda: object()) + monkeypatch.setattr(bt, "_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(bt, "_get_cdp_override", lambda: "") + monkeypatch.setattr(bt, "_get_cloud_provider", lambda: object()) + monkeypatch.setattr( + bt, "_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(bt, "_get_cdp_override", lambda: "") + monkeypatch.setattr(bt, "_get_cloud_provider", lambda: _BUProvider()) + monkeypatch.setattr( + bt, "_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 TestProviderPickerIntegration: diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 15828edf354e..97c6931ab47f 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -399,7 +399,9 @@ def _native_screenshot_result(result: Dict[str, Any], path: str) -> Optional[Dic return None -def _resolve_backend_cdp(env: dict, task_id: Optional[str]) -> Optional[str]: +def _resolve_backend_cdp( + env: dict, task_id: Optional[str], session_name: str = "" +) -> Optional[str]: """Point the harness at the configured browser backend's CDP endpoint. Resolution order (first hit wins): @@ -416,6 +418,12 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str]) -> Optional[str]: 4. Nothing configured: return None; the harness attaches to local Chrome (or Browser Use cloud via BU_AUTOSPAWN for legacy configs). + ``session_name`` (the tool's ``session`` argument / BU_NAME) keys the + provider session cache when set, so every distinct name gets its OWN + cloud browser and the same name reuses one — that is what makes named + sessions actually concurrent-safe on provider backends instead of all + names sharing a single per-task browser. + Returns an error string on provider failure, None on success. """ if env.get("BU_CDP_WS") or env.get("BU_CDP_URL"): @@ -460,7 +468,11 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str]) -> Optional[str]: return None try: - session_info = _get_session_info(task_id or "browser-exec-default") + # Named sessions get their OWN provider browser, keyed by name so the + # same name reuses one browser across calls and tasks, and different + # names never collide. Unnamed calls keep the per-task key. + cache_key = f"bu-named-{session_name}" if session_name else (task_id or "browser-exec-default") + session_info = _get_session_info(cache_key) except Exception as e: return ( f"Cloud browser provider {type(provider).__name__} failed to " @@ -511,13 +523,17 @@ def browser_exec( "dashes, or underscores (e.g. 'r7k2')." ) env["BU_NAME"] = session - else: - # Route through the configured browser backend (Browserbase, - # Firecrawl, Nous gateway, CDP override, …). Explicit BU_NAME cloud - # sessions manage their own browser and skip backend resolution. - backend_err = _resolve_backend_cdp(env, task_id) - if backend_err: - return tool_error(backend_err) + # Route through the configured browser backend (Browserbase, Firecrawl, + # Nous gateway, CDP override, local Chrome, …). Named sessions compose + # with the backend: BU_NAME namespaces the harness daemon (its IPC + # socket, log, and pid), and on provider backends the name additionally + # keys its own cloud browser — so concurrent sessions stop clobbering + # each other's daemon (#86894). Browser Use direct-API cloud configs + # are the one exception: the CLI manages named cloud browsers natively, + # and _resolve_backend_cdp skips provider resolution for them. + backend_err = _resolve_backend_cdp(env, task_id, session_name=session) + if backend_err: + return tool_error(backend_err) workspace = _workspace_dir(task_id) if workspace: @@ -616,8 +632,10 @@ def browser_exec( "Batch each sub-procedure (navigate, wait, extract, act) into one call " "— do not spend a call per action — but for long extractions prefer " "several medium calls that append to workspace files over one giant " - "call, so progress survives timeouts. For a named cloud browser, pass " - "session= (never BU_NAME env syntax)." + "call, so progress survives timeouts. For an isolated concurrent " + "browser session (parallel tasks that must not share tabs), pass " + "session= (never BU_NAME env syntax) and reuse the same name on " + "every related call." ) _HEADER_VISION = ( @@ -716,7 +734,7 @@ def _dynamic_schema_overrides() -> dict: }, "session": { "type": "string", - "description": "Named cloud browser session (sets BU_NAME). Omit for the local default daemon. Use the same name you passed to start_remote_daemon().", + "description": "Named isolated browser session (sets BU_NAME): each name gets its own harness daemon — and on cloud backends its own browser — so concurrent tasks don't clobber each other. Omit for the shared default session. Reuse the same name across calls to keep working in that session (and the name passed to start_remote_daemon(), if used).", }, "timeout_s": { "type": "integer", diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 9b064a6bc00f..99fb0e84c2a5 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -70,6 +70,8 @@ Browser Use mode uses the [Browser Use CLI 3.0](https://github.com/browser-use/b 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. +**Concurrent sessions:** `browser_exec` accepts a `session=` 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. + To opt out and force the built-in browser tools, use `/browser use off`, or: ```yaml