refactor(tools/browser): extract eval-policy/lightpanda-fallback/real-profile/snapshot modules from browser_tool; split supervisor dialogs/frames; dedupe camofox/cli
This commit is contained in:
+75
-705
@@ -10,6 +10,10 @@ Not in the agent's tool schema. Output reaches the agent via
|
||||
``browser_snapshot`` (merges supervisor state, see ``tools/browser_tool.py``)
|
||||
and ``browser_dialog`` (calls ``respond_to_dialog()``).
|
||||
Design spec: ``website/docs/developer-guide/browser-supervisor.md``.
|
||||
|
||||
Dialog capture lives in ``tools.browser_supervisor_dialogs``, frame tracking in
|
||||
``tools.browser_supervisor_frames``; both are mixed into ``CDPSupervisor`` and
|
||||
their public names are re-exported here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,8 +23,32 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from tools.browser_supervisor_dialogs import ( # noqa: F401 — re-exported
|
||||
DEFAULT_DIALOG_POLICY,
|
||||
DEFAULT_DIALOG_TIMEOUT_S,
|
||||
DIALOG_BRIDGE_HOST,
|
||||
DIALOG_BRIDGE_URL_PATTERN,
|
||||
DIALOG_POLICY_AUTO_ACCEPT,
|
||||
DIALOG_POLICY_AUTO_DISMISS,
|
||||
DIALOG_POLICY_MUST_RESPOND,
|
||||
RECENT_DIALOGS_MAX,
|
||||
_DIALOG_BRIDGE_SCRIPT,
|
||||
_VALID_POLICIES,
|
||||
DialogRecord,
|
||||
DialogSupervisionMixin,
|
||||
PendingDialog,
|
||||
_redact_supervisor_text,
|
||||
_trim_ring,
|
||||
)
|
||||
from tools.browser_supervisor_frames import ( # noqa: F401 — re-exported
|
||||
FRAME_TREE_MAX_ENTRIES,
|
||||
FRAME_TREE_MAX_OOPIF_DEPTH,
|
||||
FrameInfo,
|
||||
FrameTrackingMixin,
|
||||
)
|
||||
|
||||
# ``websockets`` costs ~22 ms at import and is only needed once a supervisor
|
||||
# connects; with postponed annotations the type import stays under TYPE_CHECKING.
|
||||
@@ -29,6 +57,9 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ring buffer of recent console-level events.
|
||||
CONSOLE_HISTORY_MAX = 50
|
||||
|
||||
|
||||
def _redact_cdp_error_text(exc: object) -> str:
|
||||
"""Redact CDP endpoint credentials from an exception's string form.
|
||||
@@ -46,187 +77,23 @@ def _redact_cdp_error_text(exc: object) -> str:
|
||||
return "<error redacted>"
|
||||
|
||||
|
||||
def _redact_supervisor_text(value: str) -> str:
|
||||
"""Redact page-originated text before exposing supervisor snapshots."""
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
return redact_sensitive_text(value, force=True)
|
||||
class _LoopUnavailable(RuntimeError):
|
||||
"""The supervisor loop refused new work (closed / shutting down)."""
|
||||
|
||||
|
||||
# ── Config defaults ───────────────────────────────────────────────────────────
|
||||
def _schedule(coro, loop, *, timeout: float):
|
||||
"""Run ``coro`` on the supervisor loop from a sync caller and wait for its result."""
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
|
||||
DIALOG_POLICY_MUST_RESPOND = "must_respond"
|
||||
DIALOG_POLICY_AUTO_DISMISS = "auto_dismiss"
|
||||
DIALOG_POLICY_AUTO_ACCEPT = "auto_accept"
|
||||
|
||||
_VALID_POLICIES = frozenset(
|
||||
{DIALOG_POLICY_MUST_RESPOND, DIALOG_POLICY_AUTO_DISMISS, DIALOG_POLICY_AUTO_ACCEPT}
|
||||
)
|
||||
|
||||
DEFAULT_DIALOG_POLICY = DIALOG_POLICY_MUST_RESPOND
|
||||
DEFAULT_DIALOG_TIMEOUT_S = 300.0
|
||||
|
||||
# Snapshot caps for frame_tree — keep payloads bounded on ad-heavy pages.
|
||||
FRAME_TREE_MAX_ENTRIES = 30
|
||||
FRAME_TREE_MAX_OOPIF_DEPTH = 2
|
||||
|
||||
# Ring buffer of recent console-level events.
|
||||
CONSOLE_HISTORY_MAX = 50
|
||||
|
||||
# Last N closed dialogs kept in ``recent_dialogs`` so agents on backends that
|
||||
# auto-dismiss server-side (Browserbase) can still observe that a dialog fired.
|
||||
RECENT_DIALOGS_MAX = 20
|
||||
|
||||
# Magic host the injected dialog bridge XHRs to. Intercepted via the CDP Fetch
|
||||
# domain before any network resolution, so it never has to exist. Keep ASCII +
|
||||
# URL-safe; Fetch patterns are gated on it.
|
||||
DIALOG_BRIDGE_HOST = "hermes-dialog-bridge.invalid"
|
||||
DIALOG_BRIDGE_URL_PATTERN = f"http://{DIALOG_BRIDGE_HOST}/*"
|
||||
|
||||
# Injected into every frame via Page.addScriptToEvaluateOnNewDocument. Overrides
|
||||
# alert/confirm/prompt to round-trip through a sync XHR we intercept via
|
||||
# Fetch.requestPaused. Works on Browserbase (whose CDP proxy auto-dismisses REAL
|
||||
# native dialogs) because the native dialogs never fire.
|
||||
_DIALOG_BRIDGE_SCRIPT = r"""
|
||||
(() => {
|
||||
if (window.__hermesDialogBridgeInstalled) return;
|
||||
window.__hermesDialogBridgeInstalled = true;
|
||||
const ENDPOINT = "http://hermes-dialog-bridge.invalid/";
|
||||
function ask(kind, message, defaultPrompt) {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Use GET with query params so we don't need to worry about request
|
||||
// body encoding in the Fetch interceptor.
|
||||
const params = new URLSearchParams({
|
||||
kind: String(kind || ""),
|
||||
message: String(message == null ? "" : message),
|
||||
default_prompt: String(defaultPrompt == null ? "" : defaultPrompt),
|
||||
});
|
||||
xhr.open("GET", ENDPOINT + "?" + params.toString(), false); // sync
|
||||
xhr.send(null);
|
||||
if (xhr.status !== 200) return null;
|
||||
const body = xhr.responseText || "";
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch (e) { return null; }
|
||||
if (kind === "alert") return undefined;
|
||||
if (kind === "confirm") return Boolean(parsed && parsed.accept);
|
||||
if (kind === "prompt") {
|
||||
if (!parsed || !parsed.accept) return null;
|
||||
return parsed.prompt_text == null ? "" : String(parsed.prompt_text);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
// If the bridge is unreachable, fall back to the native call so the
|
||||
// page still sees *some* behavior (the backend will auto-dismiss).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const realAlert = window.alert;
|
||||
const realConfirm = window.confirm;
|
||||
const realPrompt = window.prompt;
|
||||
window.alert = function(message) { ask("alert", message, ""); };
|
||||
window.confirm = function(message) {
|
||||
const r = ask("confirm", message, "");
|
||||
return r === null ? false : Boolean(r);
|
||||
};
|
||||
window.prompt = function(message, def) {
|
||||
const r = ask("prompt", message, def == null ? "" : def);
|
||||
return r === null ? null : String(r);
|
||||
};
|
||||
// onbeforeunload — we can't really synchronously prompt the user from this
|
||||
// event without racing navigation. Leave native behavior for now; the
|
||||
// supervisor's native-dialog fallback path still surfaces them in
|
||||
// recent_dialogs.
|
||||
})();
|
||||
"""
|
||||
fut = safe_schedule_threadsafe(coro, loop)
|
||||
if fut is None:
|
||||
raise _LoopUnavailable("Browser supervisor loop unavailable")
|
||||
return fut.result(timeout=timeout)
|
||||
|
||||
|
||||
# ── Data model ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingDialog:
|
||||
"""A JS dialog currently open on some frame's session."""
|
||||
|
||||
id: str
|
||||
type: str # "alert" | "confirm" | "prompt" | "beforeunload"
|
||||
message: str
|
||||
default_prompt: str
|
||||
opened_at: float
|
||||
cdp_session_id: str # which attached CDP session the dialog fired in
|
||||
frame_id: Optional[str] = None
|
||||
# Set when captured via the bridge XHR path: respond via Fetch.fulfillRequest,
|
||||
# NOT Page.handleJavaScriptDialog — the native dialog never fired.
|
||||
bridge_request_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"message": _redact_supervisor_text(self.message),
|
||||
"default_prompt": _redact_supervisor_text(self.default_prompt),
|
||||
"opened_at": self.opened_at,
|
||||
"frame_id": self.frame_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DialogRecord:
|
||||
"""A dialog that was opened and then handled (kept briefly in ``recent_dialogs``)."""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
message: str
|
||||
opened_at: float
|
||||
closed_at: float
|
||||
closed_by: str # "agent" | "auto_policy" | "remote" | "watchdog"
|
||||
frame_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"message": _redact_supervisor_text(self.message),
|
||||
"opened_at": self.opened_at,
|
||||
"closed_at": self.closed_at,
|
||||
"closed_by": self.closed_by,
|
||||
"frame_id": self.frame_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameInfo:
|
||||
"""One frame in the page's frame tree.
|
||||
|
||||
``is_oopif`` frames have their own CDP target (reachable via
|
||||
``cdp_session_id``); same-origin / srcdoc iframes share the parent process
|
||||
and have ``is_oopif=False`` + ``cdp_session_id=None``.
|
||||
"""
|
||||
|
||||
frame_id: str
|
||||
url: str
|
||||
origin: str
|
||||
parent_frame_id: Optional[str]
|
||||
is_oopif: bool
|
||||
cdp_session_id: Optional[str] = None
|
||||
name: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = {
|
||||
"frame_id": self.frame_id,
|
||||
"url": self.url,
|
||||
"origin": self.origin,
|
||||
"is_oopif": self.is_oopif,
|
||||
}
|
||||
if self.cdp_session_id:
|
||||
d["session_id"] = self.cdp_session_id
|
||||
if self.parent_frame_id:
|
||||
d["parent_frame_id"] = self.parent_frame_id
|
||||
if self.name:
|
||||
d["name"] = self.name
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsoleEvent:
|
||||
"""Ring buffer entry for console + exception traffic."""
|
||||
@@ -263,7 +130,7 @@ class SupervisorSnapshot:
|
||||
# ── Supervisor core ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CDPSupervisor:
|
||||
class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
"""One supervisor per (task_id, cdp_url) pair.
|
||||
|
||||
``start()`` spawns a daemon thread running its own asyncio loop, connects,
|
||||
@@ -311,7 +178,6 @@ class CDPSupervisor:
|
||||
self._pending_calls: Dict[int, asyncio.Future] = {}
|
||||
self._ws: Optional[ClientConnection] = None
|
||||
self._page_session_id: Optional[str] = None
|
||||
self._child_sessions: Dict[str, Dict[str, Any]] = {} # session_id -> info
|
||||
|
||||
# Dialog auto-dismiss watchdog handles (per dialog id) + id generator.
|
||||
self._dialog_watchdogs: Dict[str, asyncio.TimerHandle] = {}
|
||||
@@ -366,15 +232,9 @@ class CDPSupervisor:
|
||||
# self._ws`` returns cleanly, ``_run`` hits its ``finally``, pending
|
||||
# tasks cancel in order, THEN the thread exits.
|
||||
try:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
fut = safe_schedule_threadsafe(self._close_ws(), loop)
|
||||
if fut is not None:
|
||||
try:
|
||||
fut.result(timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
except RuntimeError:
|
||||
pass # loop already shutting down
|
||||
_schedule(self._close_ws(), loop, timeout=2.0)
|
||||
except Exception:
|
||||
pass # loop already shutting down / close timed out
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=timeout)
|
||||
with self._state_lock:
|
||||
@@ -383,20 +243,15 @@ class CDPSupervisor:
|
||||
def snapshot(self) -> SupervisorSnapshot:
|
||||
"""Return an immutable snapshot of current state."""
|
||||
with self._state_lock:
|
||||
dialogs = tuple(self._pending_dialogs.values())
|
||||
recent = tuple(self._recent_dialogs[-RECENT_DIALOGS_MAX:])
|
||||
frames_tree = self._build_frame_tree_locked()
|
||||
console = tuple(self._console_events[-CONSOLE_HISTORY_MAX:])
|
||||
active = self._active
|
||||
return SupervisorSnapshot(
|
||||
pending_dialogs=dialogs,
|
||||
recent_dialogs=recent,
|
||||
frame_tree=frames_tree,
|
||||
console_errors=console,
|
||||
active=active,
|
||||
cdp_url=self.cdp_url,
|
||||
task_id=self.task_id,
|
||||
)
|
||||
return SupervisorSnapshot(
|
||||
pending_dialogs=tuple(self._pending_dialogs.values()),
|
||||
recent_dialogs=tuple(self._recent_dialogs[-RECENT_DIALOGS_MAX:]),
|
||||
frame_tree=self._build_frame_tree_locked(),
|
||||
console_errors=tuple(self._console_events[-CONSOLE_HISTORY_MAX:]),
|
||||
active=self._active,
|
||||
cdp_url=self.cdp_url,
|
||||
task_id=self.task_id,
|
||||
)
|
||||
|
||||
def respond_to_dialog(
|
||||
self,
|
||||
@@ -438,26 +293,24 @@ class CDPSupervisor:
|
||||
}
|
||||
else:
|
||||
dialog = pending[0]
|
||||
snapshot_copy = dialog
|
||||
|
||||
loop = self._loop
|
||||
if loop is None:
|
||||
return {"ok": False, "error": "supervisor loop is not running"}
|
||||
|
||||
async def _do_respond():
|
||||
return await self._handle_dialog_cdp(
|
||||
snapshot_copy, accept=(action == "accept"), prompt_text=prompt_text or ""
|
||||
)
|
||||
|
||||
try:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
fut = safe_schedule_threadsafe(_do_respond(), loop)
|
||||
if fut is None:
|
||||
return {"ok": False, "error": "Browser supervisor loop unavailable"}
|
||||
fut.result(timeout=timeout)
|
||||
_schedule(
|
||||
self._handle_dialog_cdp(
|
||||
dialog, accept=(action == "accept"), prompt_text=prompt_text or ""
|
||||
),
|
||||
loop,
|
||||
timeout=timeout,
|
||||
)
|
||||
except _LoopUnavailable as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
return {"ok": True, "dialog": snapshot_copy.to_dict()}
|
||||
return {"ok": True, "dialog": dialog.to_dict()}
|
||||
|
||||
def evaluate_runtime(
|
||||
self,
|
||||
@@ -487,8 +340,8 @@ class CDPSupervisor:
|
||||
if not session_id:
|
||||
return {"ok": False, "error": "supervisor has no attached page session"}
|
||||
|
||||
async def _do_eval(by_value: bool) -> Dict[str, Any]:
|
||||
return await self._cdp(
|
||||
def _run_eval(by_value: bool) -> Dict[str, Any]:
|
||||
coro = self._cdp(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
"expression": expression,
|
||||
@@ -500,14 +353,7 @@ class CDPSupervisor:
|
||||
session_id=session_id,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
|
||||
def _run_eval(by_value: bool) -> Dict[str, Any]:
|
||||
fut = safe_schedule_threadsafe(_do_eval(by_value), loop)
|
||||
if fut is None:
|
||||
raise RuntimeError("Browser supervisor loop unavailable")
|
||||
return fut.result(timeout=timeout + 1)
|
||||
return _schedule(coro, loop, timeout=timeout + 1)
|
||||
|
||||
try:
|
||||
response = _run_eval(return_by_value)
|
||||
@@ -529,8 +375,7 @@ class CDPSupervisor:
|
||||
exception_details = result_payload.get("exceptionDetails")
|
||||
if exception_details:
|
||||
exc_text = exception_details.get("text") or "JavaScript exception"
|
||||
exc_obj = exception_details.get("exception") or {}
|
||||
description = exc_obj.get("description")
|
||||
description = (exception_details.get("exception") or {}).get("description")
|
||||
if description:
|
||||
exc_text = f"{exc_text}: {description}"
|
||||
return {"ok": False, "error": exc_text}
|
||||
@@ -626,12 +471,11 @@ class CDPSupervisor:
|
||||
|
||||
reader_task = asyncio.create_task(self._read_loop(), name="cdp-reader")
|
||||
try:
|
||||
# Reset per-connection session ids. ``_pending_dialogs`` and
|
||||
# ``_frames`` are deliberately kept — they reconcile as fresh
|
||||
# Reset the per-connection page session id. ``_pending_dialogs``
|
||||
# and ``_frames`` are deliberately kept — they reconcile as fresh
|
||||
# events arrive; worst case a stale dialog entry is rejected
|
||||
# with "no dialog is showing" (logged, not surfaced).
|
||||
self._page_session_id = None
|
||||
self._child_sessions.clear()
|
||||
await self._attach_initial_page()
|
||||
with self._state_lock:
|
||||
self._active = True
|
||||
@@ -691,68 +535,9 @@ class CDPSupervisor:
|
||||
{"targetId": target_id, "flatten": True},
|
||||
)
|
||||
self._page_session_id = attach["result"]["sessionId"]
|
||||
await self._cdp("Page.enable", session_id=self._page_session_id)
|
||||
await self._cdp("Runtime.enable", session_id=self._page_session_id)
|
||||
await self._cdp(
|
||||
"Target.setAutoAttach",
|
||||
{"autoAttach": True, "waitForDebuggerOnStart": False, "flatten": True},
|
||||
session_id=self._page_session_id,
|
||||
)
|
||||
await self._enable_page_domains(self._page_session_id, timeout=10.0)
|
||||
await self._install_dialog_bridge(self._page_session_id)
|
||||
|
||||
async def _install_dialog_bridge(self, session_id: str) -> None:
|
||||
"""Install the dialog-bridge init script + Fetch interceptor on a session.
|
||||
|
||||
The JS override runs in every frame before page scripts; Fetch.enable
|
||||
scoped to the bridge URL catches the XHRs, which surface as pending
|
||||
dialogs and are fulfilled when the agent responds. Idempotent at the CDP
|
||||
level (Chromium de-dupes identical add-script calls; Fetch.enable
|
||||
replaces prior patterns).
|
||||
"""
|
||||
try:
|
||||
await self._cdp(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
{"source": _DIALOG_BRIDGE_SCRIPT, "runImmediately": True},
|
||||
session_id=session_id,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"dialog bridge: addScriptToEvaluateOnNewDocument failed on sid=%s: %s",
|
||||
(session_id or "")[:16], e,
|
||||
)
|
||||
try:
|
||||
await self._cdp(
|
||||
"Fetch.enable",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"urlPattern": DIALOG_BRIDGE_URL_PATTERN,
|
||||
"requestStage": "Request",
|
||||
}
|
||||
],
|
||||
"handleAuthRequests": False,
|
||||
},
|
||||
session_id=session_id,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"dialog bridge: Fetch.enable failed on sid=%s: %s",
|
||||
(session_id or "")[:16], e,
|
||||
)
|
||||
# Best-effort inject into the already-loaded document so existing pages
|
||||
# pick up the override on reconnect.
|
||||
try:
|
||||
await self._cdp(
|
||||
"Runtime.evaluate",
|
||||
{"expression": _DIALOG_BRIDGE_SCRIPT, "returnByValue": True},
|
||||
session_id=session_id,
|
||||
timeout=3.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _cdp(
|
||||
self,
|
||||
method: str,
|
||||
@@ -817,369 +602,6 @@ class CDPSupervisor:
|
||||
if result is not None:
|
||||
await result
|
||||
|
||||
async def _on_dialog_opening(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
dialog = self._new_dialog(
|
||||
type=str(params.get("type") or ""),
|
||||
message=str(params.get("message") or ""),
|
||||
default_prompt=str(params.get("defaultPrompt") or ""),
|
||||
session_id=session_id,
|
||||
frame_id=params.get("frameId"),
|
||||
)
|
||||
self._admit_dialog(dialog, self._auto_handle_dialog)
|
||||
|
||||
def _new_dialog(
|
||||
self,
|
||||
*,
|
||||
type: str,
|
||||
message: str,
|
||||
default_prompt: str,
|
||||
session_id: Optional[str],
|
||||
frame_id: Optional[str],
|
||||
bridge_request_id: Optional[str] = None,
|
||||
) -> PendingDialog:
|
||||
self._dialog_seq += 1
|
||||
return PendingDialog(
|
||||
id=f"d-{self._dialog_seq}",
|
||||
type=type,
|
||||
message=message,
|
||||
default_prompt=default_prompt,
|
||||
opened_at=time.time(),
|
||||
cdp_session_id=session_id or self._page_session_id or "",
|
||||
frame_id=frame_id,
|
||||
bridge_request_id=bridge_request_id,
|
||||
)
|
||||
|
||||
def _admit_dialog(
|
||||
self,
|
||||
dialog: PendingDialog,
|
||||
responder: Callable[..., Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""Apply the dialog policy: auto-respond via ``responder`` or queue + arm watchdog.
|
||||
|
||||
Auto policies archive FIRST (tagged ``auto_policy``) so the ``closed``
|
||||
event that follows our own response isn't re-archived as ``remote``.
|
||||
"""
|
||||
if self.dialog_policy == DIALOG_POLICY_AUTO_DISMISS:
|
||||
with self._state_lock:
|
||||
self._archive_dialog_locked(dialog, "auto_policy")
|
||||
asyncio.create_task(responder(dialog, accept=False, prompt_text=""))
|
||||
elif self.dialog_policy == DIALOG_POLICY_AUTO_ACCEPT:
|
||||
with self._state_lock:
|
||||
self._archive_dialog_locked(dialog, "auto_policy")
|
||||
asyncio.create_task(
|
||||
responder(dialog, accept=True, prompt_text=dialog.default_prompt)
|
||||
)
|
||||
else:
|
||||
# must_respond → add to pending and arm watchdog.
|
||||
with self._state_lock:
|
||||
self._pending_dialogs[dialog.id] = dialog
|
||||
loop = asyncio.get_running_loop()
|
||||
handle = loop.call_later(
|
||||
self.dialog_timeout_s,
|
||||
lambda: asyncio.create_task(self._dialog_timeout_expired(dialog.id)),
|
||||
)
|
||||
self._dialog_watchdogs[dialog.id] = handle
|
||||
|
||||
async def _native_handle_dialog(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]
|
||||
) -> None:
|
||||
"""Page.handleJavaScriptDialog; ``promptText`` sent only for prompt dialogs
|
||||
when ``prompt_text`` is given. Raises on CDP failure."""
|
||||
params: Dict[str, Any] = {"accept": accept}
|
||||
if prompt_text is not None and dialog.type == "prompt":
|
||||
params["promptText"] = prompt_text
|
||||
await self._cdp(
|
||||
"Page.handleJavaScriptDialog",
|
||||
params,
|
||||
session_id=dialog.cdp_session_id or None,
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
async def _auto_handle_dialog(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
|
||||
) -> None:
|
||||
"""Auto-policy response for a native dialog (already archived by the caller)."""
|
||||
try:
|
||||
await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text)
|
||||
except Exception as e:
|
||||
logger.debug("auto-handle CDP call failed for %s: %s", dialog.id, e)
|
||||
|
||||
def _retire_dialog(self, dialog_id: str, closed_by: str) -> None:
|
||||
"""Remove a pending dialog (archiving it with ``closed_by``) and cancel its watchdog."""
|
||||
with self._state_lock:
|
||||
dialog = self._pending_dialogs.pop(dialog_id, None)
|
||||
if dialog is not None:
|
||||
self._archive_dialog_locked(dialog, closed_by)
|
||||
handle = self._dialog_watchdogs.pop(dialog_id, None)
|
||||
if handle is not None:
|
||||
handle.cancel()
|
||||
|
||||
async def _dialog_timeout_expired(self, dialog_id: str) -> None:
|
||||
with self._state_lock:
|
||||
dialog = self._pending_dialogs.get(dialog_id)
|
||||
if dialog is None:
|
||||
return
|
||||
logger.warning(
|
||||
"CDP supervisor %s: dialog %s (%s) auto-dismissed after %ss timeout",
|
||||
self.task_id,
|
||||
dialog_id,
|
||||
dialog.type,
|
||||
self.dialog_timeout_s,
|
||||
)
|
||||
try:
|
||||
# Archive with watchdog tag BEFORE unblocking the page.
|
||||
with self._state_lock:
|
||||
if self._pending_dialogs.pop(dialog_id, None) is not None:
|
||||
self._archive_dialog_locked(dialog, "watchdog")
|
||||
if dialog.bridge_request_id:
|
||||
await self._fulfill_bridge_request(dialog, accept=False, prompt_text="")
|
||||
else:
|
||||
await self._native_handle_dialog(dialog, accept=False, prompt_text=None)
|
||||
except Exception as e:
|
||||
logger.debug("auto-dismiss failed for %s: %s", dialog_id, e)
|
||||
|
||||
def _archive_dialog_locked(self, dialog: PendingDialog, closed_by: str) -> None:
|
||||
"""Move a pending dialog to the recent_dialogs ring buffer. Must hold state_lock."""
|
||||
record = DialogRecord(
|
||||
id=dialog.id,
|
||||
type=dialog.type,
|
||||
message=dialog.message,
|
||||
opened_at=dialog.opened_at,
|
||||
closed_at=time.time(),
|
||||
closed_by=closed_by,
|
||||
frame_id=dialog.frame_id,
|
||||
)
|
||||
self._recent_dialogs.append(record)
|
||||
if len(self._recent_dialogs) > RECENT_DIALOGS_MAX * 2:
|
||||
self._recent_dialogs = self._recent_dialogs[-RECENT_DIALOGS_MAX:]
|
||||
|
||||
async def _handle_dialog_cdp(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
|
||||
) -> None:
|
||||
"""Agent response path: bridge-fulfill for XHR-captured dialogs, else native CDP.
|
||||
|
||||
The dialog is retired regardless of outcome — a CDP error usually means
|
||||
it already closed (browser auto-dismissed after navigation, etc.).
|
||||
"""
|
||||
try:
|
||||
if dialog.bridge_request_id:
|
||||
await self._fulfill_bridge_request(
|
||||
dialog, accept=accept, prompt_text=prompt_text
|
||||
)
|
||||
else:
|
||||
await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text)
|
||||
finally:
|
||||
self._retire_dialog(dialog.id, "agent")
|
||||
|
||||
async def _on_dialog_closed(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
# ``Page.javascriptDialogClosed`` carries only ``result``/``userInput``, not
|
||||
# the message. Match by session id and clear the oldest native dialog on
|
||||
# it — the JS thread blocks while a dialog is up, so at most one is in
|
||||
# flight per session. Bridge dialogs resolve via Fetch.fulfillRequest.
|
||||
with self._state_lock:
|
||||
candidate_ids = [
|
||||
d.id
|
||||
for d in self._pending_dialogs.values()
|
||||
if d.cdp_session_id == session_id and d.bridge_request_id is None
|
||||
]
|
||||
if candidate_ids:
|
||||
self._retire_dialog(candidate_ids[0], "remote")
|
||||
|
||||
async def _on_fetch_paused(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
"""Bridge XHR captured mid-flight — materialize as a pending dialog.
|
||||
|
||||
The page's JS thread is blocked on the XHR until we Fetch.fulfillRequest
|
||||
(from ``respond_to_dialog`` or the watchdog).
|
||||
"""
|
||||
url = str(params.get("request", {}).get("url") or "")
|
||||
request_id = params.get("requestId")
|
||||
if not request_id:
|
||||
return
|
||||
if DIALOG_BRIDGE_HOST not in url:
|
||||
# Not ours — forward unchanged so the page sees its own request.
|
||||
try:
|
||||
await self._cdp(
|
||||
"Fetch.continueRequest", {"requestId": request_id},
|
||||
session_id=session_id, timeout=3.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
q = parse_qs(urlparse(url).query)
|
||||
|
||||
def _q(name: str) -> str:
|
||||
v = q.get(name, [""])
|
||||
return v[0] if v else ""
|
||||
|
||||
dialog = self._new_dialog(
|
||||
type=_q("kind") or "alert",
|
||||
message=_q("message"),
|
||||
default_prompt=_q("default_prompt"),
|
||||
session_id=session_id,
|
||||
frame_id=params.get("frameId"),
|
||||
bridge_request_id=str(request_id),
|
||||
)
|
||||
self._admit_dialog(dialog, self._fulfill_bridge_request)
|
||||
|
||||
async def _fulfill_bridge_request(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
|
||||
) -> None:
|
||||
"""Resolve a bridge XHR via Fetch.fulfillRequest so the page unblocks."""
|
||||
if not dialog.bridge_request_id:
|
||||
return
|
||||
payload = {
|
||||
"accept": bool(accept),
|
||||
"prompt_text": prompt_text if dialog.type == "prompt" else "",
|
||||
"dialog_id": dialog.id,
|
||||
}
|
||||
body = json.dumps(payload).encode()
|
||||
try:
|
||||
import base64 as _b64
|
||||
await self._cdp(
|
||||
"Fetch.fulfillRequest",
|
||||
{
|
||||
"requestId": dialog.bridge_request_id,
|
||||
"responseCode": 200,
|
||||
"responseHeaders": [
|
||||
{"name": "Content-Type", "value": "application/json"},
|
||||
{"name": "Access-Control-Allow-Origin", "value": "*"},
|
||||
],
|
||||
"body": _b64.b64encode(body).decode(),
|
||||
},
|
||||
session_id=dialog.cdp_session_id or None,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("bridge fulfill failed for %s: %s", dialog.id, e)
|
||||
|
||||
# ── Frame / target tracking ─────────────────────────────────────────────
|
||||
|
||||
def _on_frame_attached(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
frame_id = params.get("frameId")
|
||||
if not frame_id:
|
||||
return
|
||||
with self._state_lock:
|
||||
self._frames[frame_id] = FrameInfo(
|
||||
frame_id=frame_id,
|
||||
url="",
|
||||
origin="",
|
||||
parent_frame_id=params.get("parentFrameId"),
|
||||
is_oopif=False,
|
||||
cdp_session_id=session_id,
|
||||
)
|
||||
|
||||
def _on_frame_navigated(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
frame = params.get("frame") or {}
|
||||
frame_id = frame.get("id")
|
||||
if not frame_id:
|
||||
return
|
||||
with self._state_lock:
|
||||
existing = self._frames.get(frame_id)
|
||||
self._frames[frame_id] = FrameInfo(
|
||||
frame_id=frame_id,
|
||||
url=str(frame.get("url") or ""),
|
||||
origin=str(frame.get("securityOrigin") or frame.get("origin") or ""),
|
||||
parent_frame_id=frame.get("parentId") or (existing.parent_frame_id if existing else None),
|
||||
is_oopif=bool(existing.is_oopif if existing else False),
|
||||
cdp_session_id=existing.cdp_session_id if existing else session_id,
|
||||
name=str(frame.get("name") or (existing.name if existing else "")),
|
||||
)
|
||||
|
||||
def _on_frame_detached(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
"""Drop a frame only when it's truly gone.
|
||||
|
||||
``reason="swap"`` means the frame is migrating processes (e.g. promoted
|
||||
to an OOPIF) — dropping it would hide the iframe, so it's a no-op. Even
|
||||
with ``reason="remove"`` the parent only knows the child left ITS
|
||||
process; if we hold a live child session for that frame_id it is still
|
||||
alive, so keep it until Target.detached + a later frameDetached clear it.
|
||||
"""
|
||||
frame_id = params.get("frameId")
|
||||
if not frame_id:
|
||||
return
|
||||
reason = str(params.get("reason") or "remove").lower()
|
||||
if reason == "swap":
|
||||
return
|
||||
with self._state_lock:
|
||||
existing = self._frames.get(frame_id)
|
||||
if existing and existing.is_oopif and existing.cdp_session_id:
|
||||
return
|
||||
self._frames.pop(frame_id, None)
|
||||
|
||||
async def _on_target_attached(self, params: Dict[str, Any], session_id: Optional[str] = None) -> None:
|
||||
info = params.get("targetInfo") or {}
|
||||
sid = params.get("sessionId")
|
||||
target_type = info.get("type")
|
||||
if not sid or target_type not in {"iframe", "worker"}:
|
||||
return
|
||||
self._child_sessions[sid] = {"info": info, "type": target_type}
|
||||
|
||||
# Record the frame with its OOPIF session id for interaction routing.
|
||||
if target_type == "iframe":
|
||||
target_id = info.get("targetId")
|
||||
with self._state_lock:
|
||||
existing = self._frames.get(target_id)
|
||||
self._frames[target_id] = FrameInfo(
|
||||
frame_id=target_id,
|
||||
url=str(info.get("url") or ""),
|
||||
origin="", # filled by frameNavigated on the child session
|
||||
parent_frame_id=(existing.parent_frame_id if existing else None),
|
||||
is_oopif=True,
|
||||
cdp_session_id=sid,
|
||||
name=str(info.get("title") or (existing.name if existing else "")),
|
||||
)
|
||||
|
||||
# Enable child domains off-loop: awaiting the replies here would deadlock
|
||||
# because only the reader can resolve those Futures.
|
||||
asyncio.create_task(self._enable_child_domains(sid))
|
||||
|
||||
async def _enable_child_domains(self, sid: str) -> None:
|
||||
"""Enable Page+Runtime (+nested setAutoAttach) and the dialog bridge on a child session."""
|
||||
try:
|
||||
await self._cdp("Page.enable", session_id=sid, timeout=3.0)
|
||||
await self._cdp("Runtime.enable", session_id=sid, timeout=3.0)
|
||||
await self._cdp(
|
||||
"Target.setAutoAttach",
|
||||
{"autoAttach": True, "waitForDebuggerOnStart": False, "flatten": True},
|
||||
session_id=sid,
|
||||
timeout=3.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("child session %s setup failed: %s", sid[:16], e)
|
||||
await self._install_dialog_bridge(sid)
|
||||
|
||||
def _on_target_detached(self, params: Dict[str, Any], session_id: Optional[str] = None) -> None:
|
||||
"""Clear the session binding of frames on a detached child session.
|
||||
|
||||
Frames are deliberately NOT dropped: Browserbase fires transient detaches
|
||||
during page transitions while the iframe is still visible, and dropping
|
||||
would hide OOPIFs until the next ``Target.attachedToTarget``. Clearing
|
||||
``cdp_session_id`` just stops stale routing; ``Page.frameDetached``
|
||||
cleans up if the iframe truly goes away.
|
||||
"""
|
||||
sid = params.get("sessionId")
|
||||
if not sid:
|
||||
return
|
||||
self._child_sessions.pop(sid, None)
|
||||
with self._state_lock:
|
||||
for fid, frame in list(self._frames.items()):
|
||||
if frame.cdp_session_id == sid:
|
||||
self._frames[fid] = replace(frame, cdp_session_id=None)
|
||||
|
||||
# ── Console / exception ring buffer ─────────────────────────────────────
|
||||
|
||||
def _on_console(self, params: Dict[str, Any], *, level_from: str) -> None:
|
||||
@@ -1201,69 +623,17 @@ class CDPSupervisor:
|
||||
event = ConsoleEvent(ts=time.time(), level=level, text=" ".join(parts))
|
||||
with self._state_lock:
|
||||
self._console_events.append(event)
|
||||
if len(self._console_events) > CONSOLE_HISTORY_MAX * 2:
|
||||
# Keep last CONSOLE_HISTORY_MAX; 2x slack reduces churn.
|
||||
self._console_events = self._console_events[-CONSOLE_HISTORY_MAX:]
|
||||
self._console_events = _trim_ring(self._console_events, CONSOLE_HISTORY_MAX)
|
||||
|
||||
# CDP event → handler(self, params, session_id). Async handlers return an
|
||||
# awaitable that ``_on_event`` awaits; sync handlers return None.
|
||||
_EVENT_HANDLERS: Dict[str, Callable[..., Any]] = {
|
||||
"Page.javascriptDialogOpening": _on_dialog_opening,
|
||||
"Page.javascriptDialogClosed": _on_dialog_closed,
|
||||
"Fetch.requestPaused": _on_fetch_paused,
|
||||
"Page.frameAttached": _on_frame_attached,
|
||||
"Page.frameNavigated": _on_frame_navigated,
|
||||
"Page.frameDetached": _on_frame_detached,
|
||||
"Target.attachedToTarget": _on_target_attached,
|
||||
"Target.detachedFromTarget": _on_target_detached,
|
||||
**DialogSupervisionMixin.EVENT_HANDLERS,
|
||||
**FrameTrackingMixin.EVENT_HANDLERS,
|
||||
"Runtime.consoleAPICalled": lambda self, p, _sid: self._on_console(p, level_from="api"),
|
||||
"Runtime.exceptionThrown": lambda self, p, _sid: self._on_console(p, level_from="exception"),
|
||||
}
|
||||
|
||||
# ── Frame tree building (bounded) ───────────────────────────────────────
|
||||
|
||||
def _build_frame_tree_locked(self) -> Dict[str, Any]:
|
||||
"""Build the capped frame_tree payload. Must be called under state lock."""
|
||||
frames = self._frames
|
||||
empty = {"top": None, "children": [], "truncated": False}
|
||||
if not frames:
|
||||
return empty
|
||||
|
||||
# Top frame: one with no parent, preferring oopif=False.
|
||||
tops = [f for f in frames.values() if not f.parent_frame_id]
|
||||
top = next((f for f in tops if not f.is_oopif), tops[0] if tops else None)
|
||||
if top is None:
|
||||
return empty
|
||||
|
||||
# BFS from top, capped by FRAME_TREE_MAX_ENTRIES and
|
||||
# FRAME_TREE_MAX_OOPIF_DEPTH for OOPIF branches.
|
||||
children: List[Dict[str, Any]] = []
|
||||
truncated = False
|
||||
queue: List[Tuple[FrameInfo, int]] = [
|
||||
(f, 1) for f in frames.values() if f.parent_frame_id == top.frame_id
|
||||
]
|
||||
visited: set[str] = {top.frame_id}
|
||||
while queue and len(children) < FRAME_TREE_MAX_ENTRIES:
|
||||
frame, depth = queue.pop(0)
|
||||
if frame.frame_id in visited:
|
||||
continue
|
||||
visited.add(frame.frame_id)
|
||||
if frame.is_oopif and depth > FRAME_TREE_MAX_OOPIF_DEPTH:
|
||||
truncated = True
|
||||
continue
|
||||
children.append(frame.to_dict())
|
||||
for f in frames.values():
|
||||
if f.parent_frame_id == frame.frame_id and f.frame_id not in visited:
|
||||
queue.append((f, depth + 1))
|
||||
if queue:
|
||||
truncated = True
|
||||
|
||||
return {
|
||||
"top": top.to_dict(),
|
||||
"children": children,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
|
||||
# ── Registry ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user