diff --git a/tests/tools/test_browser_cdp_tool.py b/tests/tools/test_browser_cdp_tool.py index fed1b9c6f5bb..77820c190632 100644 --- a/tests/tools/test_browser_cdp_tool.py +++ b/tests/tools/test_browser_cdp_tool.py @@ -570,6 +570,244 @@ def fake_supervisor_route(**kwargs): assert len(supervisor_calls) == 1 +def test_target_id_route_blocked_when_current_page_is_private(monkeypatch): + """target_id supervisor routing must not bypass the private-page guard — + same boundary as the stateless and frame_id paths.""" + supervisor_calls = [] + stateless_calls = [] + + monkeypatch.setattr( + browser_cdp_tool, + "_resolve_cdp_endpoint", + lambda: "ws://127.0.0.1:9222/devtools/browser/mock", + ) + + import tools.browser_tool as bt + + monkeypatch.setattr(bt_eval_policy, "_eval_ssrf_guard_active", lambda task_id: True) + monkeypatch.setattr(bt_eval_policy, "_current_page_private_url", lambda task_id: PRIVATE_URL) + + def fake_target_route(**kwargs): + supervisor_calls.append(kwargs) + return json.dumps({"success": True, "result": {"value": "private data"}}) + + monkeypatch.setattr( + browser_cdp_tool, "_browser_cdp_target_via_supervisor", fake_target_route + ) + + async def fake_call(*args, **kwargs): + stateless_calls.append((args, kwargs)) + return {"result": {"value": "private data"}} + + monkeypatch.setattr(browser_cdp_tool, "_cdp_call", fake_call) + + result = json.loads( + browser_cdp_tool.browser_cdp( + method="Runtime.evaluate", + params={"expression": "document.body.innerText"}, + target_id="TARGET-1", + task_id="task-1", + ) + ) + + assert "error" in result + assert PRIVATE_URL in result["error"] + assert "private or internal address" in result["error"] + assert supervisor_calls == [] + assert stateless_calls == [] + + +def test_target_id_route_falls_back_to_stateless_without_supervisor(cdp_server): + """No live supervisor for the task → target_id path uses the legacy + stateless attach flow unchanged (and reports no session_id).""" + cdp_server.on( + "Target.attachToTarget", lambda params, sid: {"sessionId": "sess-1"} + ) + cdp_server.on( + "Runtime.evaluate", lambda params, sid: {"result": {"value": 7}} + ) + + result = json.loads( + browser_cdp_tool.browser_cdp( + method="Runtime.evaluate", + params={"expression": "3 + 4", "returnByValue": True}, + target_id="TARGET-STATELESS", + task_id="no-supervisor-task", + ) + ) + + assert result.get("success") is True + assert result.get("target_id") == "TARGET-STATELESS" + assert "session_id" not in result + + +def test_target_id_route_via_supervisor_redacts_secret_result(monkeypatch): + """The supervisor-backed target payload redacts its result like the + stateless payload does — supervisor routing must not become the + unredacted sibling path.""" + import asyncio as _asyncio + import threading as _threading + + from tools.browser_supervisor import CDPSupervisor + + sup = object.__new__(CDPSupervisor) + sup._state_lock = _threading.Lock() + sup._active = True + sup._page_target_id = "TARGET-PAGE" + sup._page_session_id = "sess-page" + sup._frames = {} + sup._child_sessions = {} + + loop = _asyncio.new_event_loop() + + def _runner(): + _asyncio.set_event_loop(loop) + loop.run_forever() + + thread = _threading.Thread(target=_runner, daemon=True) + thread.start() + + fake_key = "sk-" + "CDPSECRETRESULT1234567890" + + async def _fake_cdp(method, params=None, *, session_id=None, timeout=10.0): + return {"result": {"result": {"type": "string", "value": fake_key}}} + + sup._cdp = _fake_cdp # type: ignore[method-assign] + sup._loop = loop + + class _Registry: + def get(self, task_id): + return sup + + monkeypatch.setattr( + "tools.browser_supervisor.SUPERVISOR_REGISTRY", _Registry() + ) + + try: + result = json.loads( + browser_cdp_tool._browser_cdp_target_via_supervisor( + task_id="task-1", + target_id="TARGET-PAGE", + method="Runtime.evaluate", + params={"expression": "leak()"}, + timeout=5.0, + ) + ) + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=2) + + assert result["success"] is True + assert result["session_id"] == "sess-page" + serialized = json.dumps(result) + assert "CDPSECRETRESULT" not in serialized + assert result["result"]["result"]["value"].startswith("sk-") + + +def test_discovery_without_target_id_routes_via_supervisor(monkeypatch): + """Browser-level calls (no target_id — e.g. Target.getTargets + discovery) must ride the supervisor's WebSocket too. A stateless + discovery call on a Browserless-style backend enumerates a *different* + private browser than the one target_id-routed calls execute in, so the + reported Target.getTargets → target_id workflow only becomes coherent + when both shapes share the supervisor connection.""" + import asyncio as _asyncio + import threading as _threading + + from tools.browser_supervisor import CDPSupervisor + + sup = object.__new__(CDPSupervisor) + sup._state_lock = _threading.Lock() + sup._active = True + sup._page_target_id = "TARGET-PAGE" + sup._page_session_id = "sess-page" + sup._frames = {} + sup._child_sessions = {} + + loop = _asyncio.new_event_loop() + + def _runner(): + _asyncio.set_event_loop(loop) + loop.run_forever() + + thread = _threading.Thread(target=_runner, daemon=True) + thread.start() + + seen = [] + + async def _fake_cdp(method, params=None, *, session_id=None, timeout=10.0): + seen.append({"method": method, "session_id": session_id}) + return { + "result": { + "targetInfos": [{"targetId": "TARGET-PAGE", "type": "page"}] + } + } + + sup._cdp = _fake_cdp # type: ignore[method-assign] + sup._loop = loop + + class _Registry: + def get(self, task_id): + return sup + + monkeypatch.setattr( + "tools.browser_supervisor.SUPERVISOR_REGISTRY", _Registry() + ) + monkeypatch.setattr( + browser_cdp_tool, + "_resolve_cdp_endpoint", + lambda: "ws://127.0.0.1:9222/devtools/browser/mock", + ) + + async def _no_stateless(*args, **kwargs): + pytest.fail("stateless _cdp_call must not run while a supervisor is live") + + monkeypatch.setattr(browser_cdp_tool, "_cdp_call", _no_stateless) + + try: + result = json.loads( + browser_cdp_tool.browser_cdp( + method="Target.getTargets", + task_id="task-1", + ) + ) + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=2) + + assert result["success"] is True + assert result["connection"] == "supervisor" + # Browser-level dispatch: no sessionId on the wire, no session in the payload. + assert seen == [{"method": "Target.getTargets", "session_id": None}] + assert "session_id" not in result + assert "target_id" not in result + infos = result["result"]["targetInfos"] + assert infos[0]["targetId"] == "TARGET-PAGE" + + +def test_discovery_without_supervisor_falls_back_to_stateless(cdp_server): + """No live supervisor → browser-level calls keep the legacy stateless + connection (the plain-Chrome path, where every connection sees the + shared browser).""" + cdp_server.on( + "Target.getTargets", + lambda params, sid: { + "targetInfos": [{"targetId": "TARGET-SHARED", "type": "page"}] + }, + ) + + result = json.loads( + browser_cdp_tool.browser_cdp( + method="Target.getTargets", + task_id="no-supervisor-task", + ) + ) + + assert result.get("success") is True + assert result.get("connection") != "supervisor" + assert result["result"]["targetInfos"][0]["targetId"] == "TARGET-SHARED" + + def test_page_navigate_to_private_url_blocked_before_cdp(monkeypatch): calls = [] diff --git a/tests/tools/test_browser_supervisor.py b/tests/tools/test_browser_supervisor.py index 6e56cc69e67d..be9c1192ee0b 100644 --- a/tests/tools/test_browser_supervisor.py +++ b/tests/tools/test_browser_supervisor.py @@ -309,6 +309,126 @@ def test_browser_dialog_tool_end_to_end(chrome_cdp, supervisor_registry): assert "PYTEST-TOOL-END2END" in r["dialog"]["message"] +def test_supervisor_snapshot_exposes_page_target_id(chrome_cdp, supervisor_registry): + """The attached page's target id is discoverable via the public snapshot. + + This is the supervisor-backed target-discovery path for + ``browser_cdp(target_id=...)`` session reuse: agents read + ``page_target_id`` from ``browser_snapshot`` output (which embeds + ``SupervisorSnapshot.to_dict()``) instead of poking supervisor + internals. + """ + cdp_url, _port = chrome_cdp + sv = supervisor_registry.get_or_start( + task_id="target-discovery-test", cdp_url=cdp_url + ) + snap = sv.snapshot() + assert snap.active + assert snap.page_target_id, "snapshot must expose the attached page target id" + assert snap.to_dict().get("page_target_id") == snap.page_target_id + # The discovered id resolves to the live page session. + assert sv.resolve_target_session(snap.page_target_id) + + +def test_browser_cdp_target_id_routes_via_supervisor( + chrome_cdp, supervisor_registry, monkeypatch +): + """browser_cdp(target_id=...) reuses the live supervisor session. + + Discovers the target purely through the public snapshot path — no + private supervisor attributes. The ``session_id`` field in the payload + is the regression signal: the stateless attach path never reports one, + so this test fails without supervisor routing. + """ + cdp_url, _port = chrome_cdp + from tools import browser_cdp_tool as cdp_tool + + monkeypatch.setattr(cdp_tool, "_resolve_cdp_endpoint", lambda: cdp_url) + + sv = supervisor_registry.get_or_start(task_id="target-id-test", cdp_url=cdp_url) + snap = sv.snapshot() + assert snap.active + target_id = snap.page_target_id + assert target_id + + result = cdp_tool.browser_cdp( + method="Runtime.evaluate", + params={"expression": "1 + 2", "returnByValue": True}, + target_id=target_id, + task_id="target-id-test", + ) + r = json.loads(result) + assert r.get("success") is True, f"expected success, got: {r}" + assert r.get("target_id") == target_id + assert r.get("session_id"), "supervisor route must report the reused session id" + assert r.get("session_id") == sv.resolve_target_session(target_id) + value = r.get("result", {}).get("result", {}).get("value") + assert value == 3, f"expected 3, got {value!r}" + + +def test_browser_cdp_discovery_to_evaluate_rides_one_connection( + chrome_cdp, supervisor_registry, monkeypatch +): + """The full reported workflow — Target.getTargets discovery, then + Runtime.evaluate on a discovered target_id — must ride the ONE + supervisor WebSocket end to end. + + Per-WebSocket isolation check: the stateless ``_cdp_call`` is patched to + fail the test if anything reaches it, so both the discovery call + (browser-level, no sessionId) and the evaluate call (session-scoped) + are proven to go through the supervisor's connection — the only + arrangement in which discovery results are valid inputs for the + follow-up call on Browserless-style one-browser-per-connection + backends. + """ + cdp_url, _port = chrome_cdp + from tools import browser_cdp_tool as cdp_tool + + monkeypatch.setattr(cdp_tool, "_resolve_cdp_endpoint", lambda: cdp_url) + + sv = supervisor_registry.get_or_start( + task_id="discovery-chain-test", cdp_url=cdp_url + ) + assert sv.snapshot().active + + async def _no_stateless(*args, **kwargs): + pytest.fail("stateless _cdp_call must not run while a supervisor is live") + + monkeypatch.setattr(cdp_tool, "_cdp_call", _no_stateless) + + # Step 1: discovery, browser-level on the supervisor connection. + discovery = json.loads( + cdp_tool.browser_cdp( + method="Target.getTargets", + task_id="discovery-chain-test", + ) + ) + assert discovery.get("success") is True, f"discovery failed: {discovery}" + assert discovery.get("connection") == "supervisor" + infos = discovery["result"]["targetInfos"] + page_ids = [t["targetId"] for t in infos if t.get("type") == "page"] + assert sv.snapshot().page_target_id in page_ids, ( + "discovery must see the supervisor's own attached page — proof both " + "calls observe the same browser" + ) + + # Step 2: evaluate on a discovered target id, session-scoped on the + # same connection. + evaluate = json.loads( + cdp_tool.browser_cdp( + method="Runtime.evaluate", + params={"expression": "6 * 7", "returnByValue": True}, + target_id=sv.snapshot().page_target_id, + task_id="discovery-chain-test", + ) + ) + assert evaluate.get("success") is True, f"evaluate failed: {evaluate}" + assert evaluate.get("connection") == "supervisor" + assert evaluate.get("session_id") + value = evaluate.get("result", {}).get("result", {}).get("value") + assert value == 42, f"expected 42, got {value!r}" + + def test_browser_cdp_frame_id_real_oopif_smoke_documented(): """Document that real-OOPIF E2E was manually verified — see PR #14540. diff --git a/tools/browser_cdp_tool.py b/tools/browser_cdp_tool.py index 7dd5bdc8b9cb..f347fa2ef45e 100644 --- a/tools/browser_cdp_tool.py +++ b/tools/browser_cdp_tool.py @@ -246,6 +246,10 @@ def _browser_cdp_via_supervisor(task_id: str, frame_id: str, method: str, params supervisor._cdp(method, params or {}, session_id=child_sid, timeout=timeout), loop) # type: ignore[attr-defined] if fut is None: return tool_error("CDP call via supervisor failed: loop unavailable", cdp_docs=CDP_DOCS_URL) + # The inner _cdp call enforces `timeout` itself; the +2 margin only covers loop-dispatch + # overhead so the inner, more specific CDP timeout error surfaces instead of a generic + # future timeout. Both routing paths receive the same clamped safe_timeout from the tool + # entrypoint, so their latency contracts stay symmetric. result_msg = fut.result(timeout=timeout + 2) except Exception as exc: return tool_error(f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", cdp_docs=CDP_DOCS_URL) @@ -254,13 +258,84 @@ def _browser_cdp_via_supervisor(task_id: str, frame_id: str, method: str, params "result": result_msg.get("result", {})}, ensure_ascii=False) +def _browser_cdp_target_via_supervisor(task_id: str, target_id: Optional[str], method: str, + params: Optional[Dict[str, Any]], timeout: float) -> Optional[str]: + """Route a CDP call through the task's live supervisor connection. + + Two shapes, one WebSocket: + + * ``target_id`` set — dispatch ``method`` on the supervisor session already attached to + that target (the top-level page from ``browser_snapshot``'s ``page_target_id``, an OOPIF + frame, or an auto-attached child target). + * ``target_id`` ``None`` — dispatch ``method`` as a browser-level command (no ``sessionId``) + on the same connection. Discovery calls like ``Target.getTargets`` MUST ride the + supervisor's WebSocket too: Browserless-style backends spawn a private browser per CDP + connection (#32685), so a stateless discovery call would enumerate a *different* browser + than the one a follow-up ``target_id``-routed call executes in. + + Returns ``None`` when routing isn't possible — no live supervisor for the task, or a + ``target_id`` the supervisor has no session for — so the caller falls back to the legacy + stateless attach flow (plain Chrome shares targets across connections, so statelessness + keeps working there). + """ + try: + from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found] + except Exception: # pragma: no cover — defensive + return None + + supervisor = SUPERVISOR_REGISTRY.get(task_id) + if supervisor is None: + return None + + session_id: Optional[str] = None + if target_id: + session_id = supervisor.resolve_target_session(target_id) + if not session_id: + return None + + loop = supervisor._loop # type: ignore[attr-defined] + if loop is None or not loop.is_running(): + return None + + try: + from agent.async_utils import safe_schedule_threadsafe + fut = safe_schedule_threadsafe( + supervisor._cdp(method, params or {}, session_id=session_id, timeout=timeout), loop) # type: ignore[attr-defined] + if fut is None: + return tool_error("CDP call via supervisor failed: loop unavailable", cdp_docs=CDP_DOCS_URL) + # Same +2 loop-dispatch margin as the frame_id route above (see the comment there). + result_msg = fut.result(timeout=timeout + 2) + except Exception as exc: + return tool_error(f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", cdp_docs=CDP_DOCS_URL) + + payload: Dict[str, Any] = { + "success": True, "method": method, + # Lets callers (and tests) see the call rode the supervisor's persistent connection + # rather than a stateless one. + "connection": "supervisor", + # Same force-redaction boundary as the stateless payload — supervisor routing must not + # become the unredacted sibling path. + "result": _redact_cdp_output(result_msg.get("result", {}), + always_paths=_CDP_ALWAYS_BINARY_PATHS.get(method, ()), + flagged_paths=_CDP_FLAGGED_BINARY_PATHS.get(method, ())), + } + if target_id: + payload["target_id"] = target_id + payload["session_id"] = session_id + return json.dumps(payload, ensure_ascii=False) + + def browser_cdp(method: str, params: Optional[Dict[str, Any]] = None, target_id: Optional[str] = None, frame_id: Optional[str] = None, timeout: float = 30.0, task_id: Optional[str] = None) -> str: - """Send a raw CDP command (see ``CDP_DOCS_URL``). ``target_id`` attaches a fresh stateless connection - to a tab; ``frame_id`` (OOPIF from ``browser_snapshot.frame_tree``) routes through the supervisor's live - WebSocket instead — the only reliable way to evaluate inside an iframe where fresh per-call connections - hit signed-URL expiry (Browserbase). Both paths share the same private-page/SSRF guard. Returns JSON - ``{"success": True, "method", "result"}`` or ``{"error": ...}``.""" + """Send a raw CDP command (see ``CDP_DOCS_URL``). When the task has a live CDP supervisor, the call + rides its persistent WebSocket: ``target_id`` reuses the supervisor session already attached to that + target (e.g. ``page_target_id`` from ``browser_snapshot``) and browser-level calls (no ``target_id``) + share the same connection — required on Browserless-style backends that spawn a browser per + connection. Otherwise ``target_id`` attaches a fresh stateless connection to a tab. ``frame_id`` + (OOPIF from ``browser_snapshot.frame_tree``) always routes through the supervisor — the only reliable + way to evaluate inside an iframe where fresh per-call connections hit signed-URL expiry (Browserbase). + All paths share the same private-page/SSRF guard. Returns JSON ``{"success": True, "method", "result"}`` + or ``{"error": ...}``.""" effective_task_id = task_id or "default" if frame_id: @@ -297,6 +372,21 @@ def browser_cdp(method: str, params: Optional[Dict[str, Any]] = None, target_id: except (TypeError, ValueError): safe_timeout = 30.0 safe_timeout = max(1.0, min(safe_timeout, 300.0)) + + # --- Reuse the live supervisor connection when one exists ------------ + # Runs after validation and the private-page guard above so supervisor routing cannot become + # the sibling bypass for either (the frame_id route follows the same boundary). Covers both + # shapes: target-scoped calls ride the supervisor session attached to that target, and + # browser-level calls (no target_id — e.g. Target.getTargets discovery) ride the same + # WebSocket as browser commands, so a discovery → target_id chain observes ONE browser even + # on Browserless-style backends that give every connection a private browser. Falls through + # to the stateless attach when there is no live supervisor (plain Chrome shares targets + # across connections) or the supervisor has no session for a requested target. + routed = _browser_cdp_target_via_supervisor(task_id=effective_task_id, target_id=target_id, method=method, + params=call_params, timeout=safe_timeout) + if routed is not None: + return routed + try: result = _run_async(_cdp_call(endpoint, method, call_params, target_id, safe_timeout)) except asyncio.TimeoutError as exc: @@ -342,14 +432,17 @@ def browser_cdp(method: str, params: Optional[Dict[str, Any]] = None, target_id: "**Usage rules:**\n" "- Browser-level methods (Target.*, Browser.*, Storage.*): omit target_id and frame_id.\n" "- Page-level methods (Page.*, Runtime.*, DOM.*, Emulation.*, Network.* scoped to a tab): pass " - "target_id from Target.getTargets.\n" + "target_id from Target.getTargets or browser_snapshot's page_target_id. When the target belongs to " + "the live CDP supervisor session, the call reuses that persistent WebSocket automatically (required " + "on Browserless-style backends that spawn a browser per connection); otherwise it falls back to a " + "fresh stateless attach.\n" "- **Cross-origin iframe scope** (Runtime.evaluate inside an OOPIF, Page.* targeting a frame target, " "etc.): pass frame_id from the browser_snapshot frame_tree output. This routes through the CDP " "supervisor's live connection — the only reliable way on Browserbase where stateless CDP calls hit " "signed-URL expiry.\n" - "- Each stateless call (without frame_id) is independent — sessions and event subscriptions do not " - "persist between calls. For stateful workflows, prefer the dedicated browser tools or use frame_id " - "routing." + "- Each stateless call (without frame_id or a supervisor-tracked target_id) is independent — sessions " + "and event subscriptions do not persist between calls. For stateful workflows, prefer the dedicated " + "browser tools or supervisor-routed target_id/frame_id calls." ), "parameters": { "type": "object", diff --git a/tools/browser_supervisor.py b/tools/browser_supervisor.py index 2a7903aa29a9..f2ad4ec72921 100644 --- a/tools/browser_supervisor.py +++ b/tools/browser_supervisor.py @@ -78,12 +78,18 @@ class SupervisorSnapshot: active: bool # False if supervisor is detached/stopped cdp_url: str task_id: str + # CDP target id of the attached top-level page. Public discovery path for + # ``browser_cdp(target_id=...)`` session reuse — surfaced in ``browser_snapshot`` + # output via ``to_dict`` so agents never need to read supervisor internals. + page_target_id: Optional[str] = None def to_dict(self) -> Dict[str, Any]: """Serialize for inclusion in ``browser_snapshot`` output.""" out: Dict[str, Any] = {"pending_dialogs": [d.to_dict() for d in self.pending_dialogs], "frame_tree": self.frame_tree} if self.recent_dialogs: out["recent_dialogs"] = [d.to_dict() for d in self.recent_dialogs] + if self.page_target_id: + out["page_target_id"] = self.page_target_id return out @@ -118,6 +124,7 @@ def __init__(self, task_id: str, cdp_url: str, *, dialog_policy: str = DEFAULT_D self._next_call_id = 1 self._pending_calls: Dict[int, asyncio.Future] = {} self._ws: Optional[ClientConnection] = None + self._page_target_id: Optional[str] = None self._page_session_id: Optional[str] = None # Dialog auto-dismiss watchdog handles (per dialog id) + id generator. self._dialog_watchdogs: Dict[str, asyncio.TimerHandle] = {} @@ -168,8 +175,30 @@ def snapshot(self) -> SupervisorSnapshot: recent_dialogs=tuple(self._recent_dialogs[-RECENT_DIALOGS_MAX:]), frame_tree=self._build_frame_tree_locked(), active=self._active, cdp_url=self.cdp_url, task_id=self.task_id, + page_target_id=self._page_target_id, ) + def resolve_target_session(self, target_id: str) -> Optional[str]: + """Return the live CDP session id for a target attached to this supervisor. + + Public lookup backing ``browser_cdp(target_id=...)`` session reuse: checks the + attached top-level page, then OOPIF / auto-attached child targets tracked in + ``_frames`` (their frame ids equal their target ids). Returns ``None`` when this + supervisor does not track the target — callers fall back to a stateless attach. + """ + if not target_id: + return None + with self._state_lock: + # Partial attach: _page_target_id is known but _page_session_id is not yet + # (mid-attach/reconnect). Falling through to None is deliberate — the caller's + # stateless fallback handles the call rather than racing the half-attached session. + if target_id == self._page_target_id and self._page_session_id: + return self._page_session_id + frame = self._frames.get(target_id) + if frame is not None and frame.cdp_session_id: + return frame.cdp_session_id + return None + def respond_to_dialog(self, action: str, *, prompt_text: Optional[str] = None, dialog_id: Optional[str] = None, timeout: float = 10.0) -> Dict[str, Any]: """Accept/dismiss a pending dialog (sync bridge onto the supervisor loop). Returns @@ -322,6 +351,7 @@ async def _run(self) -> None: # Reset the per-connection page session id; ``_pending_dialogs`` / ``_frames`` # are deliberately kept — they reconcile as fresh events arrive (worst case a # stale dialog entry is rejected with "no dialog is showing", logged only). + self._page_target_id = None self._page_session_id = None await self._attach_initial_page() self._set_active(True) @@ -358,6 +388,7 @@ async def _attach_initial_page(self) -> None: if page_target is None: page_target = (await self._cdp("Target.createTarget", {"url": "about:blank"}))["result"] attach = await self._cdp("Target.attachToTarget", {"targetId": page_target["targetId"], "flatten": True}) + self._page_target_id = page_target["targetId"] self._page_session_id = sid = attach["result"]["sessionId"] await self._enable_page_domains(sid, timeout=10.0) await self._install_dialog_bridge(sid) diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 0a5dbf5bb550..8abdef5e2d19 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -728,7 +728,7 @@ browser_cdp(method="Runtime.evaluate", browser_cdp(method="Network.getAllCookies") ``` -Browser-level methods (`Target.*`, `Browser.*`, `Storage.*`) omit `target_id`. Page-level methods (`Page.*`, `Runtime.*`, `DOM.*`, `Emulation.*`) require a `target_id` from `Target.getTargets`. Each stateless call is independent — sessions do not persist between calls. +Browser-level methods (`Target.*`, `Browser.*`, `Storage.*`) omit `target_id`. Page-level methods (`Page.*`, `Runtime.*`, `DOM.*`, `Emulation.*`) require a `target_id` from `Target.getTargets` or the `page_target_id` field of `browser_snapshot` output. When a live CDP supervisor exists for the session, **all** `browser_cdp` calls ride its persistent WebSocket: browser-level calls (including `Target.getTargets` discovery) dispatch on the connection itself, and target-scoped calls reuse the supervisor session already attached to that target (the top-level page, an OOPIF frame, or an auto-attached child target). Sharing one connection is what makes a discovery → `target_id` chain observe a single browser on Browserless-style backends, which spawn a private browser per CDP connection — a fresh connection there can never see targets from a previous call. Supervisor-routed responses carry `"connection": "supervisor"`. Without a live supervisor (or for a `target_id` the supervisor has no session for), the call falls back to a fresh stateless connection; such stateless calls are independent — sessions do not persist between them. Note the supervisor's browser is not necessarily the page the `browser_navigate` daemon is driving (see the supervisor re-attach work in #74216); within `browser_cdp`, supervisor state is the canonical view. **Cross-origin iframes:** pass `frame_id` (from `browser_snapshot.frame_tree.children[]` where `is_oopif=true`) to route the CDP call through the supervisor's live session for that iframe. This is how `Runtime.evaluate` inside a cross-origin iframe works on Browserbase, where stateless CDP connections would hit signed-URL expiry. Example: