d4cec15b47
Behavior-neutral structural pass over tools/*: god-file extractions into sibling modules (file_operations_common/lint/search, file_tools_paths/ read_tracking/write, code_execution_env/rpc, tool_search_catalog/names/ validation, tts_command_provider, ...), duplicate helper unification, if/elif -> dispatch tables, dead-code removal, docstring compaction. Tool schemas (get_tool_definitions) verified byte-identical to base.
1363 lines
54 KiB
Python
1363 lines
54 KiB
Python
"""Persistent CDP supervisor for browser dialog + frame detection.
|
|
|
|
One ``CDPSupervisor`` runs per Hermes ``task_id`` with a reachable CDP endpoint.
|
|
It holds one persistent WebSocket, subscribes to ``Page`` / ``Runtime`` /
|
|
``Target`` events on every attached session (top page + auto-attached OOPIF /
|
|
worker targets), and exposes pending dialogs + frame tree through a
|
|
thread-safe snapshot that tool handlers read synchronously.
|
|
|
|
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``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
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
|
|
|
|
# ``websockets`` costs ~22 ms at import and is only needed once a supervisor
|
|
# connects; with postponed annotations the type import stays under TYPE_CHECKING.
|
|
if TYPE_CHECKING:
|
|
from websockets.asyncio.client import ClientConnection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _redact_cdp_error_text(exc: object) -> str:
|
|
"""Redact CDP endpoint credentials from an exception's string form.
|
|
|
|
``websockets`` bakes the raw target URL (``?token=`` / ``user:pass@``) into
|
|
its exception messages. Every egress point that turns such an exception into
|
|
log text or a re-raised message MUST route through here; falls back to a
|
|
fixed sentinel if redaction itself raises, erring toward masking.
|
|
"""
|
|
try:
|
|
from agent.redact import redact_cdp_url
|
|
|
|
return redact_cdp_url(str(exc))
|
|
except Exception:
|
|
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)
|
|
|
|
|
|
# ── Config defaults ───────────────────────────────────────────────────────────
|
|
|
|
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.
|
|
})();
|
|
"""
|
|
|
|
|
|
# ── 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."""
|
|
|
|
ts: float
|
|
level: str # "log" | "error" | "warning" | "exception"
|
|
text: str
|
|
url: Optional[str] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SupervisorSnapshot:
|
|
"""Read-only (frozen) snapshot of supervisor state for tool handlers."""
|
|
|
|
pending_dialogs: Tuple[PendingDialog, ...]
|
|
recent_dialogs: Tuple[DialogRecord, ...]
|
|
frame_tree: Dict[str, Any]
|
|
console_errors: Tuple[ConsoleEvent, ...]
|
|
active: bool # False if supervisor is detached/stopped
|
|
cdp_url: str
|
|
task_id: str
|
|
|
|
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]
|
|
return out
|
|
|
|
|
|
# ── Supervisor core ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class CDPSupervisor:
|
|
"""One supervisor per (task_id, cdp_url) pair.
|
|
|
|
``start()`` spawns a daemon thread running its own asyncio loop, connects,
|
|
attaches to the first page target, enables domains and auto-attach.
|
|
``snapshot()`` / ``respond_to_dialog()`` / ``evaluate_runtime()`` are sync,
|
|
thread-safe bridges onto that loop; ``stop()`` tears it down. All CDP I/O
|
|
lives on the supervisor's own loop.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
task_id: str,
|
|
cdp_url: str,
|
|
*,
|
|
dialog_policy: str = DEFAULT_DIALOG_POLICY,
|
|
dialog_timeout_s: float = DEFAULT_DIALOG_TIMEOUT_S,
|
|
) -> None:
|
|
if dialog_policy not in _VALID_POLICIES:
|
|
raise ValueError(
|
|
f"Invalid dialog_policy {dialog_policy!r}; "
|
|
f"must be one of {sorted(_VALID_POLICIES)}"
|
|
)
|
|
self.task_id = task_id
|
|
self.cdp_url = cdp_url
|
|
self.dialog_policy = dialog_policy
|
|
self.dialog_timeout_s = float(dialog_timeout_s)
|
|
|
|
# State protected by ``_state_lock`` for cross-thread reads.
|
|
self._state_lock = threading.Lock()
|
|
self._pending_dialogs: Dict[str, PendingDialog] = {}
|
|
self._recent_dialogs: List[DialogRecord] = []
|
|
self._frames: Dict[str, FrameInfo] = {}
|
|
self._console_events: List[ConsoleEvent] = []
|
|
self._active = False
|
|
|
|
# Supervisor loop machinery — populated in start().
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._ready_event = threading.Event()
|
|
self._start_error: Optional[BaseException] = None
|
|
self._stop_requested = False
|
|
|
|
# CDP call tracking (runs on supervisor loop only).
|
|
self._next_call_id = 1
|
|
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] = {}
|
|
self._dialog_seq = 0
|
|
|
|
# ── Public sync API ──────────────────────────────────────────────────────
|
|
|
|
def start(self, timeout: float = 15.0) -> None:
|
|
"""Launch the background loop and block until attachment completes.
|
|
|
|
Raises whatever attach failed with (redacted). On return, dialog events
|
|
are already being captured.
|
|
"""
|
|
if self._thread and self._thread.is_alive():
|
|
return
|
|
self._ready_event.clear()
|
|
self._start_error = None
|
|
self._stop_requested = False
|
|
self._thread = threading.Thread(
|
|
target=self._thread_main,
|
|
name=f"cdp-supervisor-{self.task_id}",
|
|
daemon=True,
|
|
)
|
|
self._thread.start()
|
|
if not self._ready_event.wait(timeout=timeout):
|
|
self.stop()
|
|
try:
|
|
from agent.redact import redact_cdp_url
|
|
_safe_url = redact_cdp_url(self.cdp_url)
|
|
except Exception:
|
|
_safe_url = "<cdp_url redacted>"
|
|
raise TimeoutError(
|
|
f"CDP supervisor did not attach within {timeout}s "
|
|
f"(cdp_url={_safe_url[:80]}...)"
|
|
)
|
|
if self._start_error is not None:
|
|
err = self._start_error
|
|
self.stop()
|
|
# ``err`` is a raw ``websockets`` exception embedding the full cdp_url
|
|
# (token / userinfo). Re-raise redacted and suppress the cause
|
|
# (``from None``) so nothing leaks via message OR traceback chain.
|
|
raise RuntimeError(
|
|
f"CDP supervisor failed to start: {_redact_cdp_error_text(err)}"
|
|
) from None
|
|
|
|
def stop(self, timeout: float = 5.0) -> None:
|
|
"""Cancel the supervisor task and join the thread."""
|
|
self._stop_requested = True
|
|
loop = self._loop
|
|
if loop is not None and loop.is_running():
|
|
# Close the WebSocket from inside the loop so ``async for raw in
|
|
# 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
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=timeout)
|
|
with self._state_lock:
|
|
self._active = False
|
|
|
|
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,
|
|
)
|
|
|
|
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 ``{"ok": True, "dialog": {...}}`` or ``{"ok": False, "error": ...}``
|
|
for recoverable errors (no dialog, ambiguous dialog_id, inactive).
|
|
"""
|
|
if action not in {"accept", "dismiss"}:
|
|
return {"ok": False, "error": f"action must be 'accept' or 'dismiss', got {action!r}"}
|
|
|
|
with self._state_lock:
|
|
if not self._active:
|
|
return {"ok": False, "error": "supervisor is not active"}
|
|
pending = list(self._pending_dialogs.values())
|
|
if not pending:
|
|
return {"ok": False, "error": "no dialog is currently open"}
|
|
if dialog_id:
|
|
dialog = self._pending_dialogs.get(dialog_id)
|
|
if dialog is None:
|
|
return {
|
|
"ok": False,
|
|
"error": f"dialog_id {dialog_id!r} not found "
|
|
f"(known: {sorted(self._pending_dialogs)})",
|
|
}
|
|
elif len(pending) > 1:
|
|
return {
|
|
"ok": False,
|
|
"error": (
|
|
f"{len(pending)} pending dialogs; specify dialog_id. "
|
|
f"Candidates: {[d.id for d in pending]}"
|
|
),
|
|
}
|
|
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)
|
|
except Exception as e:
|
|
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
return {"ok": True, "dialog": snapshot_copy.to_dict()}
|
|
|
|
def evaluate_runtime(
|
|
self,
|
|
expression: str,
|
|
*,
|
|
return_by_value: bool = True,
|
|
await_promise: bool = True,
|
|
timeout: float = 10.0,
|
|
) -> Dict[str, Any]:
|
|
"""Evaluate ``expression`` in the page's Runtime context over the live WS.
|
|
|
|
Zero subprocess cost vs the agent-browser CLI ``eval``. Returns
|
|
``{"ok": True, "result": <value>, "result_type": ...}`` or
|
|
``{"ok": False, "error": ...}``. ``return_by_value=True`` JSON-serializes
|
|
the result (DevTools-console semantics); non-serializable objects come
|
|
back as a description string.
|
|
"""
|
|
loop = self._loop
|
|
if loop is None or not loop.is_running():
|
|
return {"ok": False, "error": "supervisor loop is not running"}
|
|
|
|
with self._state_lock:
|
|
if not self._active:
|
|
return {"ok": False, "error": "supervisor is not active"}
|
|
session_id = self._page_session_id
|
|
|
|
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(
|
|
"Runtime.evaluate",
|
|
{
|
|
"expression": expression,
|
|
"returnByValue": by_value,
|
|
"awaitPromise": await_promise,
|
|
# userGesture: clipboard / fullscreen APIs need user activation.
|
|
"userGesture": True,
|
|
},
|
|
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)
|
|
|
|
try:
|
|
response = _run_eval(return_by_value)
|
|
except Exception as exc:
|
|
# Deep-serializing live DOM nodes / NodeLists / Window can blow past
|
|
# CDP's recursion guard with the protocol-level error ``Object
|
|
# reference chain is too long``. Retry once with returnByValue=False
|
|
# so Chrome returns the description string instead of failing.
|
|
if return_by_value and "reference chain is too long" in str(exc).lower():
|
|
try:
|
|
response = _run_eval(False)
|
|
except Exception as exc2:
|
|
return {"ok": False, "error": f"{type(exc2).__name__}: {exc2}"}
|
|
else:
|
|
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
|
|
# Response: {"result": {"result": {"type", "value", ...}, "exceptionDetails"?}}
|
|
result_payload = response.get("result", {}) if isinstance(response, dict) else {}
|
|
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")
|
|
if description:
|
|
exc_text = f"{exc_text}: {description}"
|
|
return {"ok": False, "error": exc_text}
|
|
|
|
result_obj = result_payload.get("result", {})
|
|
result_type = result_obj.get("type", "undefined")
|
|
|
|
if "value" in result_obj:
|
|
value = result_obj["value"]
|
|
elif result_type == "undefined":
|
|
value = None
|
|
else:
|
|
# Non-serializable (functions, DOM nodes…) — give the model the
|
|
# browser's description so it gets *something*.
|
|
value = result_obj.get("description") or result_obj.get("unserializableValue")
|
|
|
|
return {"ok": True, "result": value, "result_type": result_type}
|
|
|
|
# ── Supervisor loop internals ────────────────────────────────────────────
|
|
|
|
def _thread_main(self) -> None:
|
|
"""Entry point for the supervisor's dedicated thread."""
|
|
loop = asyncio.new_event_loop()
|
|
self._loop = loop
|
|
try:
|
|
asyncio.set_event_loop(loop)
|
|
loop.run_until_complete(self._run())
|
|
except BaseException as e: # noqa: BLE001 — propagate via _start_error
|
|
if not self._ready_event.is_set():
|
|
self._start_error = e
|
|
self._ready_event.set()
|
|
else:
|
|
logger.warning("CDP supervisor %s crashed: %s", self.task_id, e)
|
|
finally:
|
|
# Flush remaining tasks before closing the loop to avoid
|
|
# "Task was destroyed but it is pending" warnings.
|
|
try:
|
|
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
|
|
for t in pending:
|
|
t.cancel()
|
|
if pending:
|
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
loop.close()
|
|
except Exception:
|
|
pass
|
|
with self._state_lock:
|
|
self._active = False
|
|
|
|
async def _close_ws(self) -> None:
|
|
"""Detach and close the current WebSocket, swallowing close errors."""
|
|
ws = self._ws
|
|
self._ws = None
|
|
if ws is not None:
|
|
try:
|
|
await ws.close()
|
|
except Exception:
|
|
pass
|
|
|
|
async def _run(self) -> None:
|
|
"""Top-level reconnecting supervisor coroutine.
|
|
|
|
Browserbase tears down the CDP socket every time a short-lived client
|
|
(e.g. agent-browser's per-command CDP client) disconnects, so on drop we
|
|
reset per-session ids, re-attach, and keep going.
|
|
"""
|
|
attempt = 0
|
|
last_success_at = 0.0
|
|
backoff = 0.5
|
|
import websockets # deferred: only supervisors that connect pay the import
|
|
while not self._stop_requested:
|
|
try:
|
|
self._ws = await asyncio.wait_for(
|
|
websockets.connect(self.cdp_url, max_size=50 * 1024 * 1024),
|
|
timeout=10.0,
|
|
)
|
|
except Exception as e:
|
|
attempt += 1
|
|
if not self._ready_event.is_set():
|
|
# Never connected once — fatal for start().
|
|
self._start_error = e
|
|
self._ready_event.set()
|
|
return
|
|
logger.warning(
|
|
"CDP supervisor %s: connect failed (attempt %s): %s",
|
|
self.task_id, attempt, _redact_cdp_error_text(e),
|
|
)
|
|
await asyncio.sleep(min(backoff, 10.0))
|
|
backoff = min(backoff * 2, 10.0)
|
|
continue
|
|
|
|
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
|
|
# 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
|
|
last_success_at = time.time()
|
|
backoff = 0.5 # reset after a successful attach
|
|
if not self._ready_event.is_set():
|
|
self._ready_event.set()
|
|
await reader_task
|
|
except BaseException as e:
|
|
if not self._ready_event.is_set():
|
|
# Never got to ready — propagate to start().
|
|
self._start_error = e
|
|
self._ready_event.set()
|
|
raise
|
|
logger.warning(
|
|
"CDP supervisor %s: session dropped after %.1fs: %s",
|
|
self.task_id,
|
|
time.time() - last_success_at,
|
|
_redact_cdp_error_text(e),
|
|
)
|
|
finally:
|
|
with self._state_lock:
|
|
self._active = False
|
|
if not reader_task.done():
|
|
reader_task.cancel()
|
|
try:
|
|
await reader_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
for handle in list(self._dialog_watchdogs.values()):
|
|
handle.cancel()
|
|
self._dialog_watchdogs.clear()
|
|
await self._close_ws()
|
|
|
|
if self._stop_requested:
|
|
return
|
|
|
|
logger.debug(
|
|
"CDP supervisor %s: reconnecting in %.1fs...", self.task_id, backoff,
|
|
)
|
|
await asyncio.sleep(backoff)
|
|
backoff = min(backoff * 2, 10.0)
|
|
|
|
async def _attach_initial_page(self) -> None:
|
|
"""Find a page target, attach flattened session, enable domains, install dialog bridge."""
|
|
resp = await self._cdp("Target.getTargets")
|
|
targets = resp.get("result", {}).get("targetInfos", [])
|
|
page_target = next((t for t in targets if t.get("type") == "page"), None)
|
|
if page_target is None:
|
|
created = await self._cdp("Target.createTarget", {"url": "about:blank"})
|
|
target_id = created["result"]["targetId"]
|
|
else:
|
|
target_id = page_target["targetId"]
|
|
|
|
attach = await self._cdp(
|
|
"Target.attachToTarget",
|
|
{"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._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,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
*,
|
|
session_id: Optional[str] = None,
|
|
timeout: float = 10.0,
|
|
) -> Dict[str, Any]:
|
|
"""Send a CDP command and await its response."""
|
|
if self._ws is None:
|
|
raise RuntimeError("supervisor WebSocket is not connected")
|
|
call_id = self._next_call_id
|
|
self._next_call_id += 1
|
|
payload: Dict[str, Any] = {"id": call_id, "method": method}
|
|
if params:
|
|
payload["params"] = params
|
|
if session_id:
|
|
payload["sessionId"] = session_id
|
|
fut: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
self._pending_calls[call_id] = fut
|
|
await self._ws.send(json.dumps(payload))
|
|
try:
|
|
return await asyncio.wait_for(fut, timeout=timeout)
|
|
finally:
|
|
self._pending_calls.pop(call_id, None)
|
|
|
|
async def _read_loop(self) -> None:
|
|
"""Continuously dispatch incoming CDP frames."""
|
|
assert self._ws is not None
|
|
try:
|
|
async for raw in self._ws:
|
|
if self._stop_requested:
|
|
break
|
|
try:
|
|
msg = json.loads(raw)
|
|
except Exception:
|
|
logger.debug("CDP supervisor: non-JSON frame dropped")
|
|
continue
|
|
if "id" in msg:
|
|
fut = self._pending_calls.pop(msg["id"], None)
|
|
if fut is not None and not fut.done():
|
|
if "error" in msg:
|
|
fut.set_exception(
|
|
RuntimeError(f"CDP error on id={msg['id']}: {msg['error']}")
|
|
)
|
|
else:
|
|
fut.set_result(msg)
|
|
elif "method" in msg:
|
|
await self._on_event(msg["method"], msg.get("params", {}), msg.get("sessionId"))
|
|
except Exception as e:
|
|
logger.debug("CDP read loop exited: %s", e)
|
|
|
|
# ── Event dispatch ──────────────────────────────────────────────────────
|
|
|
|
async def _on_event(
|
|
self, method: str, params: Dict[str, Any], session_id: Optional[str]
|
|
) -> None:
|
|
handler = self._EVENT_HANDLERS.get(method)
|
|
if handler is None:
|
|
return
|
|
result = handler(self, params, session_id)
|
|
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:
|
|
if level_from == "exception":
|
|
details = params.get("exceptionDetails") or {}
|
|
text = str(details.get("text") or "")
|
|
url = details.get("url")
|
|
event = ConsoleEvent(ts=time.time(), level="exception", text=text, url=url)
|
|
else:
|
|
raw_level = str(params.get("type") or "log")
|
|
level = "error" if raw_level in {"error", "assert"} else (
|
|
"warning" if raw_level == "warning" else "log"
|
|
)
|
|
args = params.get("args") or []
|
|
parts: List[str] = []
|
|
for a in args[:4]:
|
|
if isinstance(a, dict):
|
|
parts.append(str(a.get("value") or a.get("description") or ""))
|
|
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:]
|
|
|
|
# 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,
|
|
"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 ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _SupervisorRegistry:
|
|
"""Process-global (task_id → supervisor) map with idempotent start/stop.
|
|
|
|
One instance, exposed as ``SUPERVISOR_REGISTRY``; mutations go through ``_lock``.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self._by_task: Dict[str, CDPSupervisor] = {}
|
|
|
|
def get(self, task_id: str) -> Optional[CDPSupervisor]:
|
|
"""Return the supervisor for ``task_id`` if running, else ``None``."""
|
|
with self._lock:
|
|
return self._by_task.get(task_id)
|
|
|
|
def get_or_start(
|
|
self,
|
|
task_id: str,
|
|
cdp_url: str,
|
|
*,
|
|
dialog_policy: str = DEFAULT_DIALOG_POLICY,
|
|
dialog_timeout_s: float = DEFAULT_DIALOG_TIMEOUT_S,
|
|
start_timeout: float = 15.0,
|
|
) -> CDPSupervisor:
|
|
"""Idempotently ensure a supervisor is running for ``(task_id, cdp_url)``.
|
|
|
|
An existing supervisor bound to a different ``cdp_url`` (or unhealthy)
|
|
is stopped and replaced.
|
|
"""
|
|
with self._lock:
|
|
existing = self._by_task.get(task_id)
|
|
if existing is not None:
|
|
if existing.cdp_url == cdp_url:
|
|
thread_ok = existing._thread is not None and existing._thread.is_alive()
|
|
loop_ok = existing._loop is not None and existing._loop.is_running()
|
|
if thread_ok and loop_ok:
|
|
return existing
|
|
# URL changed or unhealthy — tear down, fall through to re-create.
|
|
self._by_task.pop(task_id, None)
|
|
if existing is not None:
|
|
existing.stop()
|
|
|
|
supervisor = CDPSupervisor(
|
|
task_id=task_id,
|
|
cdp_url=cdp_url,
|
|
dialog_policy=dialog_policy,
|
|
dialog_timeout_s=dialog_timeout_s,
|
|
)
|
|
supervisor.start(timeout=start_timeout)
|
|
with self._lock:
|
|
# Guard against a concurrent get_or_start from another thread.
|
|
already = self._by_task.get(task_id)
|
|
if already is not None and already.cdp_url == cdp_url:
|
|
supervisor.stop()
|
|
return already
|
|
self._by_task[task_id] = supervisor
|
|
return supervisor
|
|
|
|
def stop(self, task_id: str) -> None:
|
|
"""Stop and discard the supervisor for ``task_id`` if it exists."""
|
|
with self._lock:
|
|
supervisor = self._by_task.pop(task_id, None)
|
|
if supervisor is not None:
|
|
supervisor.stop()
|
|
|
|
def stop_all(self) -> None:
|
|
"""Stop every running supervisor. For shutdown / test teardown."""
|
|
with self._lock:
|
|
items = list(self._by_task.items())
|
|
self._by_task.clear()
|
|
for _, supervisor in items:
|
|
supervisor.stop()
|
|
|
|
|
|
SUPERVISOR_REGISTRY = _SupervisorRegistry()
|
|
|
|
|
|
__all__ = [
|
|
"CDPSupervisor",
|
|
"ConsoleEvent",
|
|
"DEFAULT_DIALOG_POLICY",
|
|
"DEFAULT_DIALOG_TIMEOUT_S",
|
|
"DIALOG_POLICY_AUTO_ACCEPT",
|
|
"DIALOG_POLICY_AUTO_DISMISS",
|
|
"DIALOG_POLICY_MUST_RESPOND",
|
|
"DialogRecord",
|
|
"FrameInfo",
|
|
"PendingDialog",
|
|
"SUPERVISOR_REGISTRY",
|
|
"SupervisorSnapshot",
|
|
"_SupervisorRegistry",
|
|
]
|