Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 55 additions & 7 deletions tests/tools/test_browser_use_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name> (BU_NAME cloud browser) must not consume a backend
provider session."""
def test_named_session_composes_with_provider_backend(self, tmp_path, monkeypatch):
"""session=<name> composes with a configured provider backend: the
name keys its OWN provider browser (bu-named-<name>), 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:
Expand Down
42 changes: 30 additions & 12 deletions tools/browser_use_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"):
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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=<name> (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=<name> (never BU_NAME env syntax) and reuse the same name on "
"every related call."
)

_HEADER_VISION = (
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions website/docs/user-guide/features/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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.

To opt out and force the built-in browser tools, use `/browser use off`, or:

```yaml
Expand Down
Loading