Plugins that drive the agent's browser below the browser tools (Secure ENV, catalog review on #132272) had no public way to send a raw CDP command over a task's supervisor connection, so they bumped CDPSupervisor._next_call_id, inserted futures into _pending_calls and sent on _ws. That breaks silently on any internal change and collides between plugins. SUPERVISOR_REGISTRY.capture(task_id) now returns a CapturedCDP pinned to the supervisor's current socket and default page session: call(method, params=None, *, session_id=None, timeout=10.0) -> raw reply is_valid() / page_session_id / task_id / cdp_url The validity check and the send run in one supervisor-loop step, so a call never reaches a newer connection: after a reconnect, stop or registry replacement every call raises CapturedCDPInvalid. Calls in flight when the socket drops now fail at once (the supervisor fails pending replies on teardown) instead of sitting out their timeout. No model tool, config default or new capability: in-process Python could already reach this. The handle enforces no origin/consent/ownership policy. Reduced-scope salvage of #132752 (API shape and names from that PR); its attach-reply handoff, old-wire cleanup and dispatch fences are not needed because the supervisor closes the old socket on every reconnect, which drops its sessions with it. Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com>
12 KiB
sidebar_position, title, description
| sidebar_position | title | description |
|---|---|---|
| 18 | Browser CDP Supervisor | How Hermes detects and responds to native JS dialogs and interacts with cross-origin iframes via a persistent CDP connection. |
Browser CDP Supervisor
The CDP supervisor closes two long-standing gaps in Hermes' browser tooling:
- Native JS dialogs (
alert/confirm/prompt/beforeunload) block the page's JS thread. Without supervision, the agent has no way to know a dialog is open — subsequent tool calls hang or throw opaque errors. - Cross-origin iframes (OOPIFs) are invisible to top-level
Runtime.evaluate. The agent can see iframe nodes in the DOM snapshot but can't click, type, or eval inside them without a CDP session attached to the child target.
The supervisor solves both by holding a persistent WebSocket to the backend's
CDP endpoint per browser task, surfacing pending dialogs and frame structure
into browser_snapshot, and exposing a browser_dialog tool for explicit
responses.
Backend support
| Backend | Dialog detect | Dialog respond | Frame tree | OOPIF Runtime.evaluate via browser_cdp(frame_id=...) |
|---|---|---|---|---|
Local Chrome (--remote-debugging-port) / /browser connect |
✓ | ✓ full workflow | ✓ | ✓ |
| Browserbase | ✓ (via bridge) | ✓ full workflow (via bridge) | ✓ | ✓ |
| Camofox | ✗ no CDP (REST-only) | ✗ | partial via DOM snapshot | ✗ |
Browserbase quirk. Browserbase's CDP proxy uses Playwright internally and
auto-dismisses native dialogs within ~10ms, so Page.handleJavaScriptDialog
can't keep up. The supervisor injects a bridge script via
Page.addScriptToEvaluateOnNewDocument that overrides
window.alert/confirm/prompt with a synchronous XHR to a magic host
(hermes-dialog-bridge.invalid). Fetch.enable intercepts those XHRs before
they touch the network — the dialog becomes a Fetch.requestPaused event the
supervisor captures, and respond_to_dialog fulfills via
Fetch.fulfillRequest with a JSON body the injected script decodes.
From the page's perspective, prompt() still returns the agent-supplied
string. From the agent's perspective, it's the same browser_dialog(action=...)
API either way.
Camofox is unsupported — no CDP surface, REST-only.
Architecture
CDPSupervisor
One asyncio.Task running in a background daemon thread per Hermes task_id.
Holds a persistent WebSocket to the backend's CDP endpoint. Maintains:
- Dialog queue —
List[PendingDialog]with{id, type, message, default_prompt, session_id, opened_at} - Frame tree —
Dict[frame_id, FrameInfo]with parent relationships, URL, origin, whether cross-origin child session - Session map —
Dict[session_id, SessionInfo]so interaction tools can route to the right attached session for OOPIF operations - Recent console errors — ring buffer of the last 50 for diagnostics
Subscribes on attach:
Page.enable—javascriptDialogOpening,frameAttached,frameNavigated,frameDetachedRuntime.enable—executionContextCreated,consoleAPICalled,exceptionThrownTarget.setAutoAttach {autoAttach: true, flatten: true}— surfaces child OOPIF targets; supervisor enablesPage+Runtimeon each
Thread-safe state access via a snapshot lock; tool handlers (sync) read the frozen snapshot without awaiting.
Lifecycle
- Start:
SupervisorRegistry.get_or_start(task_id, cdp_url)— called bybrowser_navigate, Browserbase session create,/browser connect. Idempotent. - Stop: session teardown or
/browser disconnect. Cancels the asyncio task, closes the WebSocket, discards state. - Dropped endpoint: after a successful attach the supervisor reconnects with
backoff (≤10 s) but gives up after
MAX_POST_ATTACH_RECONNECT_FAILURESconsecutive failures — one final warning, the thread exits and the registry entry is dropped. A dead local Chrome (its task finished) therefore never leaves a retrying thread behind; the next browser call starts a fresh supervisor. - Rebind: if the CDP URL changes (user reconnects to a new Chrome), the old supervisor is stopped and a fresh one started — state is never reused across endpoints.
Dialog policy
Configurable via config.yaml under browser.dialog_policy:
must_respond(default) — capture, surface inbrowser_snapshot, wait for explicitbrowser_dialog(action=...)call. After a 300s safety timeout with no response, auto-dismiss and log. Prevents a buggy agent from stalling forever.auto_dismiss— record and dismiss immediately; agent sees it after the fact viabrowser_stateinsidebrowser_snapshot.auto_accept— record and accept (useful forbeforeunloadwhere the workflow wants to navigate away cleanly).
Policy is per-task; no per-dialog overrides.
Trusted plugin CDP access
In-process plugin code can send raw CDP commands over a task's supervisor connection through one public call, instead of writing the supervisor's private call table or socket:
from tools.browser_supervisor import SUPERVISOR_REGISTRY
from tools.browser_supervisor_capture import CapturedCDPInvalid
cdp = SUPERVISOR_REGISTRY.capture(task_id) # raises CapturedCDPInvalid if no attached supervisor
reply = cdp.call("Runtime.evaluate", {"expression": "document.title", "returnByValue": True},
session_id=cdp.page_session_id, timeout=5)
title = reply["result"]["result"]["value"]
SUPERVISOR_REGISTRY.capture(task_id, *, timeout=10.0) -> CapturedCDPpins the supervisor's current WebSocket and the default page session attached on it. It never starts, reconnects or refocuses a supervisor.CapturedCDP.call(method, params=None, *, session_id=None, timeout=10.0) -> dictreturns the raw CDP reply ({"id", "result"}).session_id=Noneaddresses the browser endpoint (Target.*); passpage_session_idor a session you attached yourself for page domains. A CDP error reply raisesRuntimeError; no reply withintimeoutraisesTimeoutError. The call blocks its thread, so call it from a worker thread, never from the supervisor's own event loop.CapturedCDP.is_valid()stays true until the supervisor reconnects, stops, or is replaced in the registry. After that everycallraisesCapturedCDPInvalid, including a call already in flight when the socket drops. A handle never follows the supervisor to a new connection: capture again and re-check whatever state you depend on.page_session_idis the default page session at capture time. A laterfocus_pageon the supervisor does not change it.- Sessions you attach (
Target.attachToTarget) are yours to detach. A reply that arrives aftertimeoutis dropped, so a timed-out attach can leave a session attached until the connection closes; give attaches a generous timeout.
This is a trusted, in-process seam. It grants nothing in-process Python could not already reach, and it enforces no origin, consent or target-ownership policy: a plugin that acts on pages must enforce its own. It is not exposed as a model tool and has no config switch.
Agent surface
browser_dialog tool
browser_dialog(action, prompt_text=None, dialog_id=None)
action="accept"/"dismiss"→ responds to the specified or sole pending dialog (required)prompt_text=...→ text to supply to aprompt()dialogdialog_id=...→ disambiguate when multiple dialogs are queued (rare)
Tool is response-only. The agent reads pending dialogs from browser_snapshot
output before calling.
browser_snapshot extension
Adds three optional fields to the existing snapshot output when a supervisor is attached:
{
"pending_dialogs": [
{"id": "d-1", "type": "alert", "message": "Hello", "opened_at": 1650000000.0}
],
"recent_dialogs": [
{"id": "d-1", "type": "alert", "message": "...", "opened_at": 1650000000.0,
"closed_at": 1650000000.1, "closed_by": "remote"}
],
"frame_tree": {
"top": {"frame_id": "FRAME_A", "url": "https://example.com/", "origin": "https://example.com"},
"children": [
{"frame_id": "FRAME_B", "url": "about:srcdoc", "is_oopif": false},
{"frame_id": "FRAME_C", "url": "https://ads.example.net/", "is_oopif": true, "session_id": "SID_C"}
],
"truncated": false
}
}
-
pending_dialogs— dialogs currently blocking the page's JS thread. The agent must callbrowser_dialog(action=...)to respond. Empty on Browserbase because their CDP proxy auto-dismisses within ~10ms. -
recent_dialogs— ring buffer of up to 20 recently-closed dialogs with aclosed_bytag:"agent"(we responded),"auto_policy"(local auto_dismiss/auto_accept),"watchdog"(must_respond timeout hit), or"remote"(browser/backend closed it on us, e.g. Browserbase). This is how agents on Browserbase still get visibility into what happened. -
frame_tree— frame structure including cross-origin (OOPIF) children. Capped at 30 entries + OOPIF depth 2 to bound snapshot size on ad-heavy pages.truncated: truesurfaces when limits were hit; agents needing the full tree can usebrowser_cdpwithPage.getFrameTree.
No new tool schema surface for any of these — the agent reads the snapshot it already requests.
Availability gating
Both surfaces gate on _browser_cdp_check (supervisor can only run when a CDP
endpoint is reachable). On Camofox / no-backend sessions, the dialog tool is
hidden and the snapshot omits the new fields — no schema bloat.
Cross-origin iframe interaction
browser_cdp(frame_id=...) routes CDP calls (notably Runtime.evaluate)
through the supervisor's already-connected WebSocket using the OOPIF's child
sessionId. Agents pick frame_ids out of
browser_snapshot.frame_tree.children[] where is_oopif=true and pass them
to browser_cdp. For same-origin iframes (no dedicated CDP session), the
agent uses contentWindow/contentDocument from a top-level
Runtime.evaluate instead — the supervisor surfaces an error pointing at that
fallback when frame_id belongs to a non-OOPIF.
On Browserbase, this is the only reliable path for iframe interaction —
stateless CDP connections (opened per browser_cdp call) hit signed-URL
expiry, while the supervisor's long-lived connection keeps a valid session.
File layout
tools/browser_supervisor.py—CDPSupervisor,SupervisorRegistry,PendingDialog,FrameInfotools/browser_supervisor_capture.py—CapturedCDP/CapturedCDPInvalid, the trusted-plugin CDP seam behindSUPERVISOR_REGISTRY.capturetools/browser_dialog_tool.py—browser_dialogtool handlertools/browser_tool.py—browser_navigatestart-hook,browser_snapshotmerge,/browser connectreattach,_cleanup_browser_sessionteardowntoolsets.py— registersbrowser_dialoginbrowser,hermes-acp,hermes-api-server, and core toolsets (gated on CDP reachability)hermes_cli/config.py—browser.dialog_policyandbrowser.dialog_timeout_sdefaults
Non-goals
- Detection/interaction for Camofox (upstream gap; tracked separately)
- Streaming dialog/frame events live to the user (would require gateway hooks)
- Persisting dialog history across sessions (in-memory only)
- Per-iframe dialog policies (agent can express this via
dialog_id) - Replacing
browser_cdp— it stays as the escape hatch for the long tail (cookies, viewport, network throttling)
Testing
Unit tests (tests/tools/test_browser_supervisor.py) use an asyncio mock CDP
server that speaks enough of the protocol to exercise all state transitions:
attach, enable, navigate, dialog fire, dialog dismiss, frame attach/detach,
child target attach, session teardown. Real-backend E2E (Browserbase + local
Chromium-family browser) is manual — exercise via /browser connect to a
live Chromium-family browser and run the dialog/frame test cases described
above.