"""Dialog capture + response half of the CDP supervisor. Two capture paths feed the same ``PendingDialog`` queue: * native ``Page.javascriptDialogOpening`` events (answered with ``Page.handleJavaScriptDialog``), and * the injected *dialog bridge*: a page script that rewrites alert/confirm/prompt into a sync XHR to a magic host we intercept via the CDP ``Fetch`` domain and answer with ``Fetch.fulfillRequest``. Works on Browserbase, whose CDP proxy auto-dismisses real native dialogs, because the native dialog never fires. ``DialogSupervisionMixin`` is mixed into ``tools.browser_supervisor.CDPSupervisor`` and relies on the state that class initialises (``_state_lock``, ``_pending_dialogs``, ``_recent_dialogs``, ``_dialog_watchdogs``, ``_cdp`` ...). """ from __future__ import annotations import asyncio import base64 import json import logging import time from dataclasses import dataclass from typing import Any, Callable, Coroutine, Dict, Optional from urllib.parse import parse_qs, urlparse # Logger-name parity with the origin module (records must look unchanged). logger = logging.getLogger("tools.browser_supervisor") 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) def _trim_ring(events: list, keep: int) -> list: """Cap a ring buffer at ``keep`` entries once it overflows to 2x (slack reduces churn).""" return events[-keep:] if len(events) > keep * 2 else events # ── Policy / 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 # 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. _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, } # ── Mixin ───────────────────────────────────────────────────────────────────── class DialogSupervisionMixin: """Dialog event handling for ``CDPSupervisor`` (all methods run on its loop).""" 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 # ── Capture ────────────────────────────────────────────────────────────── 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) 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 q = parse_qs(urlparse(url).query) dialog = self._new_dialog( type=q.get("kind", [""])[0] or "alert", message=q.get("message", [""])[0], default_prompt=q.get("default_prompt", [""])[0], session_id=session_id, frame_id=params.get("frameId"), bridge_request_id=str(request_id), ) self._admit_dialog(dialog, self._fulfill_bridge_request) 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``. """ auto = { DIALOG_POLICY_AUTO_DISMISS: (False, ""), DIALOG_POLICY_AUTO_ACCEPT: (True, dialog.default_prompt), }.get(self.dialog_policy) if auto is not None: accept, prompt_text = auto with self._state_lock: self._archive_dialog_locked(dialog, "auto_policy") asyncio.create_task(responder(dialog, accept=accept, prompt_text=prompt_text)) return # 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 # ── Responding ─────────────────────────────────────────────────────────── async def _respond( self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str] ) -> None: """Bridge-fulfill for XHR-captured dialogs, else native CDP. Raises on native CDP failure.""" if dialog.bridge_request_id: await self._fulfill_bridge_request(dialog, accept=accept, prompt_text=prompt_text or "") else: await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text) 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 _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: 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": base64.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) 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) async def _handle_dialog_cdp( self, dialog: PendingDialog, *, accept: bool, prompt_text: str ) -> None: """Agent response path. The dialog is retired regardless of outcome — a CDP error usually means it already closed (browser auto-dismissed after navigation, etc.). """ try: await self._respond(dialog, accept=accept, prompt_text=prompt_text) finally: self._retire_dialog(dialog.id, "agent") 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") await self._respond(dialog, accept=False, prompt_text=None) except Exception as e: logger.debug("auto-dismiss failed for %s: %s", dialog_id, e) # ── Bookkeeping ────────────────────────────────────────────────────────── 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() 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) self._recent_dialogs = _trim_ring(self._recent_dialogs, RECENT_DIALOGS_MAX) 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") # CDP event → handler(self, params, session_id); merged into CDPSupervisor._EVENT_HANDLERS. EVENT_HANDLERS: Dict[str, Callable[..., Any]] = { "Page.javascriptDialogOpening": _on_dialog_opening, "Page.javascriptDialogClosed": _on_dialog_closed, "Fetch.requestPaused": _on_fetch_paused, }