Skip to content

CDP Facade & Browser Agent Server

dazeb edited this page Sep 17, 2026 · 2 revisions

CDP Facade & Browser Agent Server

This directory is the opt-in localhost control plane for browser nodes. A browser node is an Electron <webview> guest rendered on the canvas. External agents need two capabilities: a state-changing way to ask the app to open a visible browser node, and a CDP attach surface that lets Playwright/Puppeteer drive that exact guest. The design keeps both behind loopback-only, token-gated endpoints and explicitly avoids Chromium’s raw remote-debugging port.

Responsibilities at a glance

File Responsibility
runtime.ts Per-boot token for the agent-control surface. No raw Chromium --remote-debugging-port is opened.
manager.ts Secures every guest at attach, keeps the nodeId::tabId → guestId registry, and centralizes browser-node navigation/popup policy.
agent-server.ts Token-gated HTTP control server: GET /info, POST /open, discovery file userData/browser-agent.json mode 0600.
cdp-facade.ts Virtual CDP browser on its own loopback port: exposes guests as standard page targets, routes CDP sessions to guest debuggers, supports Playwright auto-attach and Puppeteer direct attach.
Tests cdp-facade.test.ts, agent-server.test.ts, manager.test.ts cover the facade protocol, control server, and manager behavior.

Security and trust boundaries

  • Both servers bind to 127.0.0.1 only, never 0.0.0.0, and use listen(0) so the OS assigns a random high port.
  • The agent control server requires an exact Authorization: Bearer <token> on every request. Missing or wrong auth returns 401.
  • The CDP facade requires the same per-boot token on every HTTP discovery call and on the WebSocket upgrade, via ?token= or Authorization: Bearer. Unauthenticated WebSocket connections are closed with code 4001.
  • The token is shared across the agent-control surface: StartAgentServerOptions.token can be passed in so the discovery file and facade metadata stay coherent. If omitted, the agent server generates a fresh random token.
  • The discovery file is written to userData/browser-agent.json with mode 0600 because it carries the bearer token. It is removed on close so a stopped endpoint is never advertised.
  • POST /open validates the requested URL through core/browser-policy before broadcasting. Empty body or empty URL becomes about:blank; a present-but-invalid URL is denied with 400.
  • Guest hardening happens in manager.ts: strip preload, force context isolation and sandbox, keep webSecurity on, block non-web will-navigate/will-redirect, and allow only sandboxed web sign-in popups sharing the guest session. Nested popups are denied.
  • Debug logging is opt-in via TERMSPRAWL_FACADE_DEBUG=1 and omits payloads, including cookie data.

The directory contract explicitly states that no raw Chromium --remote-debugging-port is opened; an earlier audit found that such a port exposed the main window’s full preload bridge to local processes. The facade exists precisely to provide a narrower, token-gated surface instead.

Call chain

  1. On boot, a per-boot token is established and shared by the agent control server and CDP facade.
  2. The agent control server writes browser-agent.json containing port, token, cdp, and open { method, path, url }.
  3. An external agent reads the discovery file or calls GET /info, then calls POST /open with a bearer token.
  4. agent-server.ts normalizes and validates the URL with normalizeAddress/ABOUT_BLANK from core/browser-policy. Invalid URLs return 400; valid URLs are broadcast to the renderer on browser:agent-open.
  5. The renderer materializes the browser node; the main-process BrowserManager records the guest under nodeId::tabId via registerBrowserGuest.
  6. The agent discovers CDP through the facade’s authenticated /json/version response, which returns a webSocketDebuggerUrl embedding the token.
  7. Playwright connectOverCDP or Puppeteer connects to that WebSocket. The upgrade token is checked before currentWs is claimed.
  8. Puppeteer uses Target.getTargets + Target.attachToTarget; Playwright uses Target.setAutoAttach and receives Target.attachedToTarget events. The facade also polls browserGuestIds() every 500 ms while auto-attach is active, emitting attach events for new guests and detach events for guests that died.
  9. attachGuest attaches Electron’s webContents.debugger, creates a facade session id, and learns the guest’s real CDP target id from Page.getFrameTree.
  10. Page-level CDP messages carrying a sessionId are routed through sessionToGuest to the guest debugger; guest debugger events are forwarded back to currentWs with the same session id.
  11. When the WebSocket closes, resetFacadeState() detaches guest debuggers, clears routing/auto-attach/target-id maps, and keeps the forwardedGuests marker to avoid installing duplicate event forwarders.
sequenceDiagram
  autonumber
  participant Agent as External Agent
  participant Server as Agent Control Server
  participant Renderer as Renderer
  participant Facade as CDP Facade

  Agent->>Server: GET /info (Bearer token)
  Server-->>Agent: { ok, cdp, open }
  Agent->>Server: POST /open { url }
  Server->>Server: normalizeAddress(url)
  alt invalid URL
    Server-->>Agent: 400 denied url
  else valid or blank
    Server->>Renderer: broadcast(browser:agent-open)
    Server-->>Agent: 200 { ok, url }
  end
  Agent->>Facade: GET /json/version (token)
  Facade-->>Agent: webSocketDebuggerUrl?token=...
  Agent->>Facade: WebSocket upgrade + CDP
  Facade->>Facade: attachGuest / route / forward
  Facade-->>Agent: Target.attachedToTarget / page events
Loading

The key nodes: the agent control server is the only state-changing entry point; the renderer owns node/guest creation; the facade is the CDP translation layer. The BrowserManager registry is what makes a canvas node addressable by its stable nodeId and tab id, and browserGuestIds() is the facade’s source of live guests.

Command translation: how a webview guest becomes a page target

Playwright’s connectOverCDP filters targets by type and does not surface Electron webview guests because they report type webview. Puppeteer and raw CDP attach fine, but a Playwright-only agent never sees the embedded browser. The facade therefore presents a minimal “virtual browser” on its own loopback port and re-exposes every live guest as a standard page target.

Two attach models are supported:

  • Puppeteer-style direct attach: Target.getTargets lists targets, and Target.attachToTarget attaches directly. attachGuest creates the session mapping and attaches the guest debugger.
  • Playwright-style auto-attach: Target.setAutoAttach turns on autoAttach. The facade attaches to every live guest, emits Target.attachedToTarget, and keeps polling for newcomers. Each attached target includes a non-empty synthetic browserContextId because Playwright asserts one and buckets pages by it; the facade uses a single shared FAKE_BROWSER_CONTEXT for all guests.

The most delicate part is target identity. Chromium reports a <webview> guest’s CDP target id as its main frame id. Playwright resolves frame sessions by that id. If the facade reported the numeric webContents id while Page.getFrameTree reported the hex frame id, Playwright would throw “Frame has been detached” and silently degrade the page to a dummy frame. To prevent that, attachGuest learns the real target id from the guest’s own Page.getFrameTree call and stores it in guestTargetIds. buildTargetInfo then reports that real id.

Browser-level Storage cookie commands map to the live guest profile, and cookie writes flush to disk. Debug logs never include cookie payloads.

Key state

State Location Purpose
currentWs cdp-facade.ts Singleton active CDP client. The facade is deliberately single-client; a second connection effectively evicts the first, matching Chromium’s one-client debug port.
sessionToGuest / guestToSession cdp-facade.ts Bidirectional session-id ↔ guest webContents id routing.
guestTargetIds cdp-facade.ts Guest id → real CDP target id, learned at attach from Page.getFrameTree.
autoAttach, autoAttachedGuests, guestPollTimer cdp-facade.ts Playwright auto-attach bookkeeping and 500 ms newcomer/dead-guest polling.
forwardedGuests cdp-facade.ts Installs exactly one debugger message forwarder per guest. This set persists across resetFacadeState() because the forwarder reads live maps and no-ops when guestToSession is empty.
FAKE_BROWSER_CONTEXT cdp-facade.ts Synthetic shared browser context id required by Playwright.
guests manager.ts nodeId::tabId → guest WebContents id. Enables driving a node by its stable canvas id.
token / discovery file agent-server.ts, runtime.ts Auth for both the control server and the CDP facade.

Boundary conditions and failure modes

  • The agent server’s request body is capped at 256 KiB; larger bodies are destroyed.
  • Malformed JSON or a missing url in POST /open opens about:blank rather than failing.
  • The agent server returns 404 for unknown routes and 401 for missing/wrong bearer auth.
  • The facade returns 401 for unauthenticated discovery endpoints and closes unauthenticated WebSocket upgrades with 4001.
  • Both servers swallow server error events: a port/socket failure simply leaves the endpoint undiscoverable rather than hanging startup.
  • attachGuest is idempotent about attaching the debugger because Electron allows only one debugger per webContents; a previous facade instance may have left it attached.
  • resetFacadeState() detaches debuggers and clears sessionToGuest, guestToSession, autoAttachedGuests, and guestTargetIds. It deliberately keeps forwardedGuests to avoid double event delivery.
  • liveGuest guards against destroyed webContents; detachGuest treats already-detached debuggers as safe.
  • guestIdForTargetId falls back to interpreting a numeric target id as a guest id if no learned mapping exists.
  • Guest lifetime is tied to the renderer’s <webview> DOM; the renderer destroys the guest webContents when a tab’s webview is removed, and unregisterBrowserGuest only updates the main-process map.
  • URL policy is centralized in src/core/browser-policy.ts (normalizeAddress, ABOUT_BLANK, isAllowedNavUrl). Navigation checks should not be inlined into these servers.
  • The source comment on StartCdpFacadeOptions.cdpInfo describes it as loopback info for the app’s raw debug port, while AGENTS.md states that no raw Chromium remote-debugging port is opened. Treat the surfaced cdp metadata as agent-facing discovery data and verify the current boot wiring before relying on it.

Extension points

  • Add or adjust CDP domains/methods in the facade’s route() path. The session maps, attachGuest, detachGuest, buildTargetInfos, emitAttached, and the per-guest event forwarder are the surrounding integration points.
  • Change auto-attach newcomer/dead-guest handling in the 500 ms guestPollTimer loop.
  • Extend the discovery payload in agent-server.ts (port, token, cdp, open) when new agent-facing metadata is needed.
  • Reuse the shared per-boot token by passing StartAgentServerOptions.token and StartCdpFacadeOptions.token.
  • Browser-tab support is already represented by nodeId::tabId; guestIdForNode accepts an optional tab id and has a legacy fallback for old nodes without a persisted tab id.
  • URL policy changes belong in src/core/browser-policy.ts, not in the loopback servers.

Sources: src/main/browser/AGENTS.md, src/main/browser/agent-server.ts, src/main/browser/cdp-facade.ts, src/main/browser/cdp-facade.ts, src/main/browser/manager.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally