cdp-bridge.ts
180 lines · browse/src/cdp-bridge.ts at 3f79d81
1/**
2 * CDP escape hatch — `$B cdp <Domain.method> [json-params]`.
3 *
4 * Path A from the spike: uses Playwright's newCDPSession() per page so we
5 * piggyback Playwright's own CDP socket (no second WebSocket, no need for
6 * --remote-debugging-port).
7 *
8 * Security posture (Codex T2):
9 * - DENY-DEFAULT. Methods must be explicitly listed in cdp-allowlist.ts.
10 * - Each entry is tagged scope (tab|browser) and output (trusted|untrusted).
11 *
12 * Concurrency posture (Codex T7):
13 * - Two-tier lock from browser-manager.ts.
14 * - tab-scoped methods take the per-tab mutex.
15 * - browser-scoped methods take the global lock that blocks all tab mutexes.
16 * - Hard 5s timeout on acquire → CDPMutexAcquireTimeout (no silent hangs).
17 * - Every lock-holder uses try { ... } finally { release() } so errors don't leak locks.
18 */
20import type { Page } from 'playwright';
21import type { BrowserManager } from './browser-manager';
22import { lookupCdpMethod, type CdpAllowEntry } from './cdp-allowlist';
23import { logTelemetry } from './telemetry';
25const CDP_TIMEOUT_MS = 5000;
26const CDP_ACQUIRE_TIMEOUT_MS = 5000;
28// ─── CDP session lifecycle helpers ─────────────────────────────
29//
30// Every direct `newCDPSession(page)` call needs a matching `session.detach()`
31// to release the Chromium-side CDP target. Forgetting the detach leaves the
32// target attached until the underlying transport drops (often process exit),
33// which on a long-lived headed browser shows up as steadily-climbing
34// browser-process RSS. To make the leak class unforgettable, callers should
35// go through one of these two helpers and a static-grep test
36// (browse/test/cdp-session-cleanup.test.ts) fails CI if any source file
37// calls `newCDPSession(` outside this module.
39/**
40 * Ephemeral CDP session with try/finally detach. Use for one-shot CDP work
41 * where the caller doesn't need session reuse — e.g. archive snapshots,
42 * `$B memory`, a single `Page.captureScreenshot`. The session is detached
43 * in `finally` regardless of whether `fn` threw, so the Chromium target
44 * doesn't leak on the error path.
45 *
46 * For repeated use of the same page (e.g. the `$B cdp` bridge or the
47 * inspector), use `getOrCreateCdpSession` instead — it caches and detaches
48 * on page close.
49 */
50export async function withCdpSession<T>(
51 page: Page,
52 fn: (session: any) => Promise<T>,
53): Promise<T> {
54 const session = await page.context().newCDPSession(page);
55 try {
56 return await fn(session);
57 } finally {
58 try {
59 await session.detach();
60 } catch {
61 // Best-effort cleanup. Session may already be detached (target closed,
62 // context recreated, browser disconnect). Swallowing all errors is the
63 // correct cleanup posture per CLAUDE.md "best-effort cleanup paths".
64 }
65 }
66}
68/**
69 * Cached long-lived CDP session keyed by Page. First call creates the
70 * session and registers a `page.once('close', ...)` hook that removes the
71 * cache entry AND calls `session.detach()`. Pre-helper code only removed
72 * the cache entry, leaving the Chromium-side target attached.
73 *
74 * Pass a caller-owned WeakMap so this helper doesn't impose a single global
75 * cache — the `$B cdp` bridge and the inspector each keep their own session
76 * pool with different invariants (e.g. the inspector also detaches on
77 * `framenavigated` because DOM/CSS domain state is tied to the document).
78 */
79export async function getOrCreateCdpSession(
80 page: Page,
81 cache: WeakMap<Page, any>,
82): Promise<any> {
83 let session = cache.get(page);
84 if (session) return session;
85 session = await page.context().newCDPSession(page);
86 cache.set(page, session);
87 page.once('close', () => {
88 cache.delete(page);
89 session.detach().catch(() => {
90 // Best-effort cleanup — see withCdpSession finally block.
91 });
92 });
93 return session;
94}
96// ─── $B cdp bridge ─────────────────────────────────────────────
98// Per-page CDPSession cache. Lifecycle delegated to getOrCreateCdpSession
99// which registers a close hook that BOTH removes the cache entry AND calls
100// session.detach() — pre-helper code only did the former, leaving the
101// Chromium-side target attached.
102const sessionCache: WeakMap<Page, any> = new WeakMap();
104async function getCdpSession(page: Page): Promise<any> {
105 return getOrCreateCdpSession(page, sessionCache);
106}
108export interface CdpDispatchInput {
109 domain: string;
110 method: string;
111 params: Record<string, unknown>;
112 tabId: number;
113 bm: BrowserManager;
114}
116export interface CdpDispatchResult {
117 raw: unknown;
118 entry: CdpAllowEntry;
119}
121/**
122 * Look up + acquire mutex + send + release. Throws structured errors on:
123 * - DENIED (method not on allowlist)
124 * - CDPMutexAcquireTimeout (lock contention exceeded budget)
125 * - CDPBridgeTimeout (CDP method itself didn't return in budget)
126 * - CDPSessionInvalidated (Playwright recreated context, session stale)
127 */
128export async function dispatchCdpCall(input: CdpDispatchInput): Promise<CdpDispatchResult> {
129 const qualified = `${input.domain}.${input.method}`;
130 const entry = lookupCdpMethod(qualified);
131 if (!entry) {
132 // Surface the denial via telemetry — this is the data that drives the
133 // next allow-list expansion (DX D9: cdp_method_denied counter).
134 logTelemetry({ event: 'cdp_method_denied', domain: input.domain, method: input.method });
135 throw new Error(
136 `DENIED: ${qualified} is not on the CDP allowlist.\n` +
137 `Cause: deny-default posture; method has not been audited and added to cdp-allowlist.ts.\n` +
138 `Action: if this method is genuinely needed, open a PR adding it to CDP_ALLOWLIST with a one-line justification + scope (tab|browser) + output (trusted|untrusted).`
139 );
140 }
141 // Acquire the right tier of lock.
142 const acquireStart = Date.now();
143 const release =
144 entry.scope === 'browser'
145 ? await input.bm.acquireGlobalCdpLock(CDP_ACQUIRE_TIMEOUT_MS)
146 : await input.bm.acquireTabLock(input.tabId, CDP_ACQUIRE_TIMEOUT_MS);
147 const acquireMs = Date.now() - acquireStart;
148 logTelemetry({ event: 'cdp_method_lock_acquire_ms', domain: input.domain, method: input.method, ms: acquireMs });
149 logTelemetry({ event: 'cdp_method_called', domain: input.domain, method: input.method, allowed: true, scope: entry.scope });
151 try {
152 const page = input.bm.getPageForTab(input.tabId);
153 if (!page) {
154 throw new Error(
155 `Cannot dispatch: tab ${input.tabId} not found.\n` +
156 'Cause: tab was closed between command queue and dispatch.\n' +
157 'Action: $B tabs to list current tabs.'
158 );
159 }
160 let session;
161 try {
162 session = await getCdpSession(page);
163 } catch (e: any) {
164 throw new Error(
165 `CDPSessionInvalidated: ${e.message}\n` +
166 'Cause: Playwright context was recreated (e.g., viewport scale change) and the prior CDP session is stale.\n' +
167 'Action: retry the command; the bridge will create a fresh session.'
168 );
169 }
170 // Race the call against a hard timeout.
171 const callPromise = session.send(qualified, input.params);
172 const timeoutPromise = new Promise((_, reject) =>
173 setTimeout(() => reject(new Error(`CDPBridgeTimeout: ${qualified} did not return within ${CDP_TIMEOUT_MS}ms`)), CDP_TIMEOUT_MS),
174 );
175 const raw = await Promise.race([callPromise, timeoutPromise]);
176 return { raw, entry };
177 } finally {
178 release();
179 }
180}