Files
hermes-agent/website/docs/developer-guide/browser-supervisor.md
Teknium 6bf7de73e1 feat(browser): public captured CDP call seam for trusted plugins
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>
2026-10-05 17:10:58 -07:00

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:

  1. 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.
  2. 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, frameDetached
  • Runtime.enable — executionContextCreated, consoleAPICalled, exceptionThrown
  • Target.setAutoAttach {autoAttach: true, flatten: true} — surfaces child OOPIF targets; supervisor enables Page+Runtime on 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 by browser_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_FAILURES consecutive 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 in browser_snapshot, wait for explicit browser_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 via browser_state inside browser_snapshot.
  • auto_accept — record and accept (useful for beforeunload where 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) -> CapturedCDP pins 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) -> dict returns the raw CDP reply ({"id", "result"}). session_id=None addresses the browser endpoint (Target.*); pass page_session_id or a session you attached yourself for page domains. A CDP error reply raises RuntimeError; no reply within timeout raises TimeoutError. 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 every call raises CapturedCDPInvalid, 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_id is the default page session at capture time. A later focus_page on the supervisor does not change it.
  • Sessions you attach (Target.attachToTarget) are yours to detach. A reply that arrives after timeout is 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 a prompt() dialog
  • dialog_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 call browser_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 a closed_by tag: "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: true surfaces when limits were hit; agents needing the full tree can use browser_cdp with Page.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, FrameInfo
  • tools/browser_supervisor_capture.py — CapturedCDP / CapturedCDPInvalid, the trusted-plugin CDP seam behind SUPERVISOR_REGISTRY.capture
  • tools/browser_dialog_tool.py — browser_dialog tool handler
  • tools/browser_tool.py — browser_navigate start-hook, browser_snapshot merge, /browser connect reattach, _cleanup_browser_session teardown
  • toolsets.py — registers browser_dialog in browser, hermes-acp, hermes-api-server, and core toolsets (gated on CDP reachability)
  • hermes_cli/config.py — browser.dialog_policy and browser.dialog_timeout_s defaults

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.