mirror of https://github.com/garrytan/gstack.git
181 lines
7.3 KiB
TypeScript
181 lines
7.3 KiB
TypeScript
/**
|
|
* CDP escape hatch — `$B cdp <Domain.method> [json-params]`.
|
|
*
|
|
* Path A from the spike: uses Playwright's newCDPSession() per page so we
|
|
* piggyback Playwright's own CDP socket (no second WebSocket, no need for
|
|
* --remote-debugging-port).
|
|
*
|
|
* Security posture (Codex T2):
|
|
* - DENY-DEFAULT. Methods must be explicitly listed in cdp-allowlist.ts.
|
|
* - Each entry is tagged scope (tab|browser) and output (trusted|untrusted).
|
|
*
|
|
* Concurrency posture (Codex T7):
|
|
* - Two-tier lock from browser-manager.ts.
|
|
* - tab-scoped methods take the per-tab mutex.
|
|
* - browser-scoped methods take the global lock that blocks all tab mutexes.
|
|
* - Hard 5s timeout on acquire → CDPMutexAcquireTimeout (no silent hangs).
|
|
* - Every lock-holder uses try { ... } finally { release() } so errors don't leak locks.
|
|
*/
|
|
|
|
import type { Page } from 'playwright';
|
|
import type { BrowserManager } from './browser-manager';
|
|
import { lookupCdpMethod, type CdpAllowEntry } from './cdp-allowlist';
|
|
import { logTelemetry } from './telemetry';
|
|
|
|
const CDP_TIMEOUT_MS = 5000;
|
|
const CDP_ACQUIRE_TIMEOUT_MS = 5000;
|
|
|
|
// ─── CDP session lifecycle helpers ─────────────────────────────
|
|
//
|
|
// Every direct `newCDPSession(page)` call needs a matching `session.detach()`
|
|
// to release the Chromium-side CDP target. Forgetting the detach leaves the
|
|
// target attached until the underlying transport drops (often process exit),
|
|
// which on a long-lived headed browser shows up as steadily-climbing
|
|
// browser-process RSS. To make the leak class unforgettable, callers should
|
|
// go through one of these two helpers and a static-grep test
|
|
// (browse/test/cdp-session-cleanup.test.ts) fails CI if any source file
|
|
// calls `newCDPSession(` outside this module.
|
|
|
|
/**
|
|
* Ephemeral CDP session with try/finally detach. Use for one-shot CDP work
|
|
* where the caller doesn't need session reuse — e.g. archive snapshots,
|
|
* `$B memory`, a single `Page.captureScreenshot`. The session is detached
|
|
* in `finally` regardless of whether `fn` threw, so the Chromium target
|
|
* doesn't leak on the error path.
|
|
*
|
|
* For repeated use of the same page (e.g. the `$B cdp` bridge or the
|
|
* inspector), use `getOrCreateCdpSession` instead — it caches and detaches
|
|
* on page close.
|
|
*/
|
|
export async function withCdpSession<T>(
|
|
page: Page,
|
|
fn: (session: any) => Promise<T>,
|
|
): Promise<T> {
|
|
const session = await page.context().newCDPSession(page);
|
|
try {
|
|
return await fn(session);
|
|
} finally {
|
|
try {
|
|
await session.detach();
|
|
} catch {
|
|
// Best-effort cleanup. Session may already be detached (target closed,
|
|
// context recreated, browser disconnect). Swallowing all errors is the
|
|
// correct cleanup posture per CLAUDE.md "best-effort cleanup paths".
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cached long-lived CDP session keyed by Page. First call creates the
|
|
* session and registers a `page.once('close', ...)` hook that removes the
|
|
* cache entry AND calls `session.detach()`. Pre-helper code only removed
|
|
* the cache entry, leaving the Chromium-side target attached.
|
|
*
|
|
* Pass a caller-owned WeakMap so this helper doesn't impose a single global
|
|
* cache — the `$B cdp` bridge and the inspector each keep their own session
|
|
* pool with different invariants (e.g. the inspector also detaches on
|
|
* `framenavigated` because DOM/CSS domain state is tied to the document).
|
|
*/
|
|
export async function getOrCreateCdpSession(
|
|
page: Page,
|
|
cache: WeakMap<Page, any>,
|
|
): Promise<any> {
|
|
let session = cache.get(page);
|
|
if (session) return session;
|
|
session = await page.context().newCDPSession(page);
|
|
cache.set(page, session);
|
|
page.once('close', () => {
|
|
cache.delete(page);
|
|
session.detach().catch(() => {
|
|
// Best-effort cleanup — see withCdpSession finally block.
|
|
});
|
|
});
|
|
return session;
|
|
}
|
|
|
|
// ─── $B cdp bridge ─────────────────────────────────────────────
|
|
|
|
// Per-page CDPSession cache. Lifecycle delegated to getOrCreateCdpSession
|
|
// which registers a close hook that BOTH removes the cache entry AND calls
|
|
// session.detach() — pre-helper code only did the former, leaving the
|
|
// Chromium-side target attached.
|
|
const sessionCache: WeakMap<Page, any> = new WeakMap();
|
|
|
|
async function getCdpSession(page: Page): Promise<any> {
|
|
return getOrCreateCdpSession(page, sessionCache);
|
|
}
|
|
|
|
export interface CdpDispatchInput {
|
|
domain: string;
|
|
method: string;
|
|
params: Record<string, unknown>;
|
|
tabId: number;
|
|
bm: BrowserManager;
|
|
}
|
|
|
|
export interface CdpDispatchResult {
|
|
raw: unknown;
|
|
entry: CdpAllowEntry;
|
|
}
|
|
|
|
/**
|
|
* Look up + acquire mutex + send + release. Throws structured errors on:
|
|
* - DENIED (method not on allowlist)
|
|
* - CDPMutexAcquireTimeout (lock contention exceeded budget)
|
|
* - CDPBridgeTimeout (CDP method itself didn't return in budget)
|
|
* - CDPSessionInvalidated (Playwright recreated context, session stale)
|
|
*/
|
|
export async function dispatchCdpCall(input: CdpDispatchInput): Promise<CdpDispatchResult> {
|
|
const qualified = `${input.domain}.${input.method}`;
|
|
const entry = lookupCdpMethod(qualified);
|
|
if (!entry) {
|
|
// Surface the denial via telemetry — this is the data that drives the
|
|
// next allow-list expansion (DX D9: cdp_method_denied counter).
|
|
logTelemetry({ event: 'cdp_method_denied', domain: input.domain, method: input.method });
|
|
throw new Error(
|
|
`DENIED: ${qualified} is not on the CDP allowlist.\n` +
|
|
`Cause: deny-default posture; method has not been audited and added to cdp-allowlist.ts.\n` +
|
|
`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).`
|
|
);
|
|
}
|
|
// Acquire the right tier of lock.
|
|
const acquireStart = Date.now();
|
|
const release =
|
|
entry.scope === 'browser'
|
|
? await input.bm.acquireGlobalCdpLock(CDP_ACQUIRE_TIMEOUT_MS)
|
|
: await input.bm.acquireTabLock(input.tabId, CDP_ACQUIRE_TIMEOUT_MS);
|
|
const acquireMs = Date.now() - acquireStart;
|
|
logTelemetry({ event: 'cdp_method_lock_acquire_ms', domain: input.domain, method: input.method, ms: acquireMs });
|
|
logTelemetry({ event: 'cdp_method_called', domain: input.domain, method: input.method, allowed: true, scope: entry.scope });
|
|
|
|
try {
|
|
const page = input.bm.getPageForTab(input.tabId);
|
|
if (!page) {
|
|
throw new Error(
|
|
`Cannot dispatch: tab ${input.tabId} not found.\n` +
|
|
'Cause: tab was closed between command queue and dispatch.\n' +
|
|
'Action: $B tabs to list current tabs.'
|
|
);
|
|
}
|
|
let session;
|
|
try {
|
|
session = await getCdpSession(page);
|
|
} catch (e: any) {
|
|
throw new Error(
|
|
`CDPSessionInvalidated: ${e.message}\n` +
|
|
'Cause: Playwright context was recreated (e.g., viewport scale change) and the prior CDP session is stale.\n' +
|
|
'Action: retry the command; the bridge will create a fresh session.'
|
|
);
|
|
}
|
|
// Race the call against a hard timeout.
|
|
const callPromise = session.send(qualified, input.params);
|
|
const timeoutPromise = new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error(`CDPBridgeTimeout: ${qualified} did not return within ${CDP_TIMEOUT_MS}ms`)), CDP_TIMEOUT_MS),
|
|
);
|
|
const raw = await Promise.race([callPromise, timeoutPromise]);
|
|
return { raw, entry };
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|