diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-28 20:25:38 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | 330767d862458386e804bde240a1f0d7835b9b8e (patch) | |
| tree | 13a5f2086d265959c1bfa10bb88641a6804fd1f5 /src | |
| parent | 12d7a38ba0f1b1e4e301fce81e22f4b93036d211 (diff) | |
refactor(cdp): strip comment noise
Removes module banners, section dividers, and comments restating the code.
Keeps one-line notes for the window.open/browserContextId constraint, the
seed-tab claim, the unverifiable-pin reuse rule, the SeedTargetProvider
rationale, the deliberate dialog retention, the discovery-time browser
quirks, and the guarded CDP decoder assertion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/cdp/attach.ts | 1 | ||||
| -rw-r--r-- | src/cdp/commands.ts | 12 | ||||
| -rw-r--r-- | src/cdp/console-buffer.ts | 34 | ||||
| -rw-r--r-- | src/cdp/daemon-transport.ts | 22 | ||||
| -rw-r--r-- | src/cdp/discovery.ts | 65 | ||||
| -rw-r--r-- | src/cdp/event-queue.ts | 39 | ||||
| -rw-r--r-- | src/cdp/network-buffer.ts | 29 | ||||
| -rw-r--r-- | src/cdp/ownership.ts | 16 | ||||
| -rw-r--r-- | src/cdp/session.ts | 81 | ||||
| -rw-r--r-- | src/cdp/target-factory.ts | 69 | ||||
| -rw-r--r-- | src/cdp/transport.ts | 16 | ||||
| -rw-r--r-- | src/cdp/window.ts | 1 |
12 files changed, 16 insertions, 369 deletions
diff --git a/src/cdp/attach.ts b/src/cdp/attach.ts index 9be539d..c1b101b 100644 --- a/src/cdp/attach.ts +++ b/src/cdp/attach.ts @@ -2,7 +2,6 @@ import { map, type Result } from "../util/result"; import type { CdpError } from "./errors"; import type { CdpSession } from "./session"; -/** Target.attachToTarget, unwrapped to the new session id. */ export const attachTo = async ( session: CdpSession, targetId: string, diff --git a/src/cdp/commands.ts b/src/cdp/commands.ts index e6d744d..580f8cf 100644 --- a/src/cdp/commands.ts +++ b/src/cdp/commands.ts @@ -78,7 +78,6 @@ const AxNode = Type.Object( export type AxNodeResult = Static<typeof AxNode>; export const COMMANDS = { - // --- Target --- "Target.attachToTarget": cmd( Type.Object({ targetId: Type.String(), flatten: Type.Optional(Type.Boolean()) }), Type.Object({ sessionId: Type.String() }), @@ -112,7 +111,6 @@ export const COMMANDS = { ), "Target.setDiscoverTargets": cmd(Type.Object({ discover: Type.Boolean() }), Empty), - // --- Browser --- "Browser.getWindowForTarget": cmd( Type.Object({ targetId: Type.Optional(Type.String()) }), Type.Object({ windowId: Type.Number(), bounds: Type.Optional(Type.Unknown()) }), @@ -126,7 +124,6 @@ export const COMMANDS = { Empty, ), - // --- Page --- "Page.enable": cmd(Type.Object({}), Empty), "Page.bringToFront": cmd(Type.Object({}), Empty), "Page.navigate": cmd( @@ -163,7 +160,6 @@ export const COMMANDS = { Empty, ), - // --- Runtime --- "Runtime.evaluate": cmd( Type.Object( { @@ -192,7 +188,6 @@ export const COMMANDS = { ), "Runtime.releaseObject": cmd(Type.Object({ objectId: Type.String() }), Empty), - // --- DOM --- "DOM.getDocument": cmd( Type.Object({ depth: Type.Optional(Type.Number()), pierce: Type.Optional(Type.Boolean()) }), Type.Object({ root: Type.Object({ nodeId: Type.Number() }, { additionalProperties: true }) }), @@ -224,13 +219,11 @@ export const COMMANDS = { Empty, ), - // --- Input --- "Input.dispatchMouseEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty), "Input.dispatchKeyEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty), "Input.dispatchDragEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty), "Input.insertText": cmd(Type.Object({ text: Type.String() }), Empty), - // --- Network / Emulation / Accessibility --- "Network.getResponseBody": cmd( Type.Object({ requestId: Type.String() }), Type.Object({ body: Type.String(), base64Encoded: Type.Optional(Type.Boolean()) }), @@ -258,9 +251,6 @@ export const decodeResult = <M extends CdpMethod>( const why = spec.validate.Errors(raw).map((e) => e.message).join("; "); return err(cdpError("invalid_response", why, method)); } - // Guarded assertion — see Verified Fact 4. The Check above proves `raw` - // matches COMMANDS[method].result; TS cannot correlate a generic key to its - // mapped value, so this carries what the check established. The only - // assertion in the CDP layer; do not add another. + // Guarded assertion: the check above proves `raw` matches COMMANDS[method].result, which TS cannot correlate from a generic key. return ok(raw as ResultOf<M>); }; diff --git a/src/cdp/console-buffer.ts b/src/cdp/console-buffer.ts index f42312f..c38f85e 100644 --- a/src/cdp/console-buffer.ts +++ b/src/cdp/console-buffer.ts @@ -1,20 +1,3 @@ -/** - * In-memory aggregator for CDP Runtime.consoleAPICalled and Log.entryAdded events. - * - * Mirrors network-buffer.ts: the single CDP event loop in session.ts hands raw - * params to ingestConsoleApi / ingestLogEntry; tools later read records via drain(). - * - * Bounded by capacity (default 500). On overflow the oldest record is evicted FIFO, - * and the overflow flag is reported once per drain so tools can warn the LLM that - * data may be missing. - * - * Each record carries a monotonic `seq`. Tools expose the last drained seq as - * `nextCursor`; callers pass it back as `sinceSeq` to read only what's new since - * the previous drain. This is the cursor pattern that makes "what did this action - * cause?" answerable in one tool call. - * - * Joining/filtering logic stays trivially testable (no transport dependency). - */ import { decodeEvent } from "./events"; @@ -22,35 +5,26 @@ export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug"; export type ConsoleRecord = { seq: number; - /** Wall-clock ms when the event was ingested, in Date.now() units. */ timestampMs: number; level: ConsoleLevel; - /** Joined args (console-api) or the entry text (log-entry). Per-arg truncated. */ text: string; source: "console-api" | "log-entry"; url?: string; lineNumber?: number; - /** Top frames only, joined with newlines. Populated for error/warn when CDP provides one. */ stackTrace?: string; }; export type ConsoleFilter = { levels?: ConsoleLevel[]; - /** Substring match by default. Wrap in slashes (`/foo/`) for regex. */ textPattern?: string; - /** Only records with seq strictly greater than this. */ sinceSeq?: number; - /** Only records with timestamp at most N ms ago. */ sinceMs?: number; - /** Cap on returned records (default 50, max 500). */ limit?: number; }; export type ConsoleDrainResult = { readonly records: ConsoleRecord[]; - /** Total matches before `limit` was applied. */ readonly total: number; - /** True if the ring buffer evicted at least one record since the last drain. */ readonly bufferOverflowed: boolean; }; @@ -61,9 +35,7 @@ export type ConsoleBuffer = { clear(): void; }; -/** Per-arg cap to stop a single console.log(hugeBlob) from blowing the buffer. */ const PER_ARG_CAP = 2000; -/** Max stack frames preserved per record. */ const MAX_STACK_FRAMES = 3; const compileTextMatcher = (pattern: string): ((text: string) => boolean) => { @@ -72,7 +44,6 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => { const re = new RegExp(pattern.slice(1, -1)); return (text) => re.test(text); } catch { - // Fall through to substring match if the pattern is not a valid regex. } } return (text) => text.includes(pattern); @@ -81,7 +52,6 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => { const truncateArg = (s: string): string => s.length > PER_ARG_CAP ? s.slice(0, PER_ARG_CAP) + "…" : s; -/** Best-effort stringification of one Runtime.RemoteObject. */ const stringifyRemoteObject = ( arg: { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string }, ): string => { @@ -98,7 +68,6 @@ const stringifyRemoteObject = ( return arg.type ?? ""; }; -/** Map CDP console-API method names to our normalized levels. */ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => { switch (type) { case "error": @@ -116,7 +85,6 @@ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => { } }; -/** Map Log.LogEntry.level values ("verbose","info","warning","error") to our levels. */ const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => { switch (level) { case "error": @@ -145,8 +113,6 @@ const formatStackTrace = ( }; export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => { - // Insertion-ordered map keyed by seq. Map preserves insertion order in JS, - // so iteration gives us oldest-first eviction without a separate index. const records = new Map<number, ConsoleRecord>(); let nextSeq = 1; let overflowed = false; diff --git a/src/cdp/daemon-transport.ts b/src/cdp/daemon-transport.ts index 819e581..101108d 100644 --- a/src/cdp/daemon-transport.ts +++ b/src/cdp/daemon-transport.ts @@ -1,15 +1,3 @@ -/** - * DaemonTransport — CdpTransport backed by a Unix socket to the browser daemon. - * - * Satisfies the exact CdpTransport interface so BrowserClient, CdpSession, and - * all domain tools work without changes. Internally uses newline-delimited JSON - * over a Unix domain socket instead of a WebSocket to Chrome. - * - * References: - * - CdpTransport interface: src/cdp/transport.ts - * - net.createConnection IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateconnection - * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions - */ import { createConnection, type Socket } from "node:net"; import { createInterface } from "node:readline"; @@ -26,7 +14,6 @@ import { serialize, } from "../daemon/protocol"; -// ── Implementation ───────────────────────────────────────────────────────────── export const createDaemonTransport = (clientId: string): CdpTransport => { let socket: Socket | null = null; @@ -49,10 +36,8 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { for (const cb of closeListeners) cb(); }; - // ── Connect ──────────────────────────────────────────────────────────── const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => { - // url is ignored — we always connect to the daemon socket const timeoutMs = opts?.timeoutMs ?? 10_000; if (socket && !socket.destroyed && registered) { @@ -133,17 +118,14 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { }); sock.on("error", () => { - // Errors are surfaced via 'close' }); sock.on("close", () => { clearTimeout(connectTimer); cleanup("Daemon socket closed"); - // If connect hasn't settled yet, settle with error settle(err(cdpError("transport_closed", "Daemon socket closed before registration"))); }); - // Send registration const regMsg: WireControl = { type: "control", action: "register", clientId }; sock.write(serialize(regMsg) + "\n"); }); @@ -158,7 +140,6 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { }); }; - // ── Close ────────────────────────────────────────────────────────────── const close = async (): Promise<void> => { if (registered && socket && !socket.destroyed) { @@ -168,7 +149,6 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { cleanup("close() called"); }; - // ── Request ──────────────────────────────────────────────────────────── const request = ( method: string, @@ -195,11 +175,9 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n")); }; - // ── Events ───────────────────────────────────────────────────────────── const events = (): AsyncIterable<CdpEvent> => queue.iter; - // ── State ────────────────────────────────────────────────────────────── const state = (): "open" | "closed" | "connecting" => { if (!socket) return "closed"; diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts index dc998b5..7b67fe0 100644 --- a/src/cdp/discovery.ts +++ b/src/cdp/discovery.ts @@ -20,13 +20,6 @@ const VersionResponse = Type.Object( const versionValidator = Compile(VersionResponse); -/** - * A live CDP endpoint plus, when discovery went through a DevToolsActivePort - * file, the user-data-dir that file lives in. That directory is the browser's - * profile registry (`Local State`) — Chromium writes DevToolsActivePort to - * chrome::DIR_USER_DATA — so it is what profile selection enumerates. - * Undefined when the endpoint came from BU_CDP_WS or a bare port probe. - */ export type CdpEndpoint = { readonly wsUrl: string; readonly userDataDir?: string; @@ -63,17 +56,9 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => { )); }; -// Fast, single-shot TCP check (no retry loop). Used to skip stale -// DevToolsActivePort files left behind by a browser that has since quit — -// unlike waitForPort, this returns immediately instead of blocking ~30s. const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success; -// Ask the live browser for its canonical webSocketDebuggerUrl via -// http://127.0.0.1:<port>/json/version. This is authoritative: it avoids -// trusting a path from a stale DevToolsActivePort file that may belong to a -// different browser which has since exited (e.g. Chrome's stale file pointing -// at 9222 while Brave actually owns the port now). Returns null if the endpoint -// is unreachable, non-200, or doesn't expose a WS URL. +// /json/version is authoritative: a stale DevToolsActivePort file may name a browser that has since exited while another now owns the port. const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> => new Promise<string | null>((resolve) => { const req = httpRequest( @@ -97,10 +82,6 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> req.end(); }); -// Well-known CDP ports to probe when no DevToolsActivePort file is readable -// (sandboxed harness, restricted filesystem permissions, or an unusual profile -// location not on the profileDirs list). The default Chromium remote-debugging -// port is 9222; users can extend via BU_CDP_PORTS="9222,9333". const fallbackPorts = (): ReadonlyArray<number> => { const ports = new Set<number>([9222]); const fromEnv = process.env["BU_CDP_PORTS"]; @@ -117,21 +98,12 @@ type Candidate = { readonly port: number; readonly path: string; readonly mtimeMs: number; - /** The user-data-dir whose DevToolsActivePort produced this candidate. */ readonly userDataDir: string; }; -/** - * Locate a live CDP endpoint, reporting the user-data-dir it belongs to when - * that is knowable. `discoverWsUrl` is the URL-only view of the same walk. - */ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => { const dirs = userDataDirCandidates(); - // Phase 1: collect every readable DevToolsActivePort candidate up front, - // rather than committing to the first one found. A single browser writes one - // such file, but stale files from quit browsers linger, so we must - // disambiguate below instead of trusting discovery order. const candidates: Candidate[] = []; const readErrors: string[] = []; for (const base of dirs) { @@ -143,13 +115,9 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> try { mtimeMs = (await stat(portFile)).mtimeMs; } catch { - // mtime is only a tiebreaker; a stat failure just leaves it at 0. } } catch (e) { - // Node fs errors carry .code; see ErrnoException. ENOENT is normal — the - // dir is on our list but the user hasn't installed that browser. EPERM/ - // EACCES is common under sandboxes; remember it and fall back to network - // probing rather than failing the whole discovery. + // EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery. const code = errnoCode(e); if (code === "ENOENT" || code === undefined) continue; if (code === "EPERM" || code === "EACCES") { @@ -163,25 +131,16 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> const port = lines[0]?.trim(); const path = lines[1]?.trim(); if (!port || !path) continue; - // Guard against a truncated/corrupt port file (e.g. a non-numeric first - // line): an out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT - // synchronously, which would escape discoverWsUrl instead of surfacing as - // a Result error. Skip such entries. + // An out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT synchronously, escaping discoverWsUrl instead of surfacing as a Result error. const portNum = Number(port); if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue; candidates.push({ port: portNum, path, mtimeMs, userDataDir: base }); } - // Phase 2: no readable candidate — probe well-known ports directly. Covers - // sandboxed harnesses that can't read profile dirs and non-default install - // locations. Requires the browser to have been launched with - // --remote-debugging-port so /json/version is served. if (candidates.length === 0) { for (const port of fallbackPorts()) { if (!(await isPortLive(port))) continue; const live = await queryLiveWsUrl(port); - // No DevToolsActivePort file was involved, so the user-data-dir behind - // this endpoint is unknown — profile selection stays unavailable. if (live) return ok({ wsUrl: live }); } const permHint = readErrors.length > 0 @@ -193,9 +152,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> )); } - // Phase 3: disambiguate candidates sharing a port (one browser running, and - // another's file left behind on the default 9222): keep the most-recently- - // written file — that's the currently-running browser. const byPort = new Map<number, Candidate>(); for (const c of candidates) { const prev = byPort.get(c.port); @@ -203,16 +159,11 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> } const unique = Array.from(byPort.values()); - // Prefer candidates whose port is actually accepting connections; this skips - // stale files whose browser has quit (avoiding a ~30s waitForPort block). const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port))); const liveCandidates = unique.filter((_, i) => liveChecks[i]); const ordered = liveCandidates.length > 0 ? liveCandidates : unique; - // Phase 4: for each candidate, prefer the browser's own canonical WS URL from - // /json/version. Some browsers disable that HTTP endpoint when remote - // debugging is toggled only via chrome://inspect (rather than a launch flag), - // so fall back to the WS path written in DevToolsActivePort. + // Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback. let lastErr: Result<CdpEndpoint, CdpError> | null = null; for (const c of ordered) { const ready = await waitForPort(c.port); @@ -224,10 +175,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir }); } - // Every discovered candidate was stale/unreachable. Before giving up, try the - // well-known fallback ports — a user with a leftover DevToolsActivePort file - // may still have a live browser reachable via 9222 / BU_CDP_PORTS. (Skip ports - // already covered by a candidate above so we don't re-probe them.) for (const port of fallbackPorts()) { if (byPort.has(port) || !(await isPortLive(port))) continue; const liveUrl = await queryLiveWsUrl(port); @@ -237,10 +184,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates")); }; -/** - * URL-only view of {@link discoverEndpoint}, kept as the stable entry point for - * callers that don't care which user-data-dir answered. - */ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { const endpoint = await discoverEndpoint(); return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint; diff --git a/src/cdp/event-queue.ts b/src/cdp/event-queue.ts index 87f5551..5e154ea 100644 --- a/src/cdp/event-queue.ts +++ b/src/cdp/event-queue.ts @@ -1,12 +1,3 @@ -/** - * Shared transport primitives for CdpTransport implementations. - * - * Both the WebSocket transport (transport.ts, talking to Chrome directly) and - * the daemon transport (daemon-transport.ts, talking to the daemon over a Unix - * socket) need an identical single-consumer async event queue plus the same - * pending-request bookkeeping. This module is the single source of truth for - * that shared machinery. - */ import { type Result, err } from "../util/result"; import { type CdpError, cdpError } from "./errors"; @@ -14,14 +5,6 @@ import type { CdpEvent } from "./types"; export type CdpResult = Result<unknown, CdpError>; -/** - * A single-consumer async queue of CDP events. - * - * `push` enqueues an event (dropped silently after `end`). `end` terminates the - * stream — any pending and future iterator `next()` calls resolve `done:true`. - * `iter` is the AsyncIterable consumed by one for-await loop; a second consumer - * would silently steal events from the first. - */ export type EventQueue = { readonly push: (e: CdpEvent) => void; readonly end: () => void; @@ -59,18 +42,12 @@ export const makeEventQueue = (): EventQueue => { }; }; -/** An in-flight CDP request awaiting its response or timeout. */ export type Pending = { readonly resolve: (v: Result<unknown, CdpError>) => void; readonly timer: ReturnType<typeof setTimeout>; readonly method: string; }; -/** - * Reject every in-flight request with a transport_closed error and clear the - * map. Called from each transport's cleanup path when the underlying connection - * goes away. - */ export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => { for (const [, p] of pending) { clearTimeout(p.timer); @@ -79,18 +56,6 @@ export const rejectAllPending = (pending: Map<number, Pending>, reason: string): pending.clear(); }; -/** - * Send a CDP request and return a promise that settles when the matching - * response arrives, the request times out, or the send throws. - * - * The transport supplies a `send` callback that puts the already-serialized - * payload onto its wire (WebSocket frame or Unix-socket line); everything else - * — registering the pending entry, the timeout timer, and synchronous send - * failure handling — is identical across transports and lives here. - * - * `timeoutLabel` is the transport's prefix for the timeout error message - * (e.g. "CDP" or "Daemon"). - */ export const sendWithTimeout = ( pending: Map<number, Pending>, id: number, @@ -114,10 +79,6 @@ export const sendWithTimeout = ( } }); -/** - * Register a close listener, returning an unsubscribe function. Identical - * across transports, so it lives here. - */ export const makeOnClose = (closeListeners: Set<() => void>) => (cb: () => void): (() => void) => { closeListeners.add(cb); diff --git a/src/cdp/network-buffer.ts b/src/cdp/network-buffer.ts index c26ae73..f6ad032 100644 --- a/src/cdp/network-buffer.ts +++ b/src/cdp/network-buffer.ts @@ -1,18 +1,3 @@ -/** - * In-memory aggregator for CDP Network.* events. - * - * The CDP event stream is consumed by a single internal loop in session.ts. - * That loop hands Network.* event params to this module, which joins them into - * one record per requestId (request → response → finished/failed). Tools then - * read records via drain(), filtered to what the caller asked for. - * - * Bounded by a fixed capacity (default 500): on overflow the oldest record is - * evicted FIFO. The overflow flag is reported once per drain so tools can warn - * the LLM that data may be missing. - * - * This module has no transport dependency, so the joining/filtering logic stays - * trivially testable in isolation. - */ import { decodeEvent } from "./events"; @@ -24,34 +9,27 @@ export type NetworkRecord = { statusText?: string; type: string; mimeType?: string; - /** Wall-clock ms when requestWillBeSent fired, in Date.now() units. */ requestStartedMs: number; durationMs?: number; requestBodySize: number; responseBodySize?: number; failed?: boolean; errorText?: string; - /** Populated by the tool layer via Network.getResponseBody, never by the aggregator. */ body?: string | null; }; export type NetworkFilter = { - /** Substring match by default. Wrap in slashes (`/foo/`) for regex. */ urlPattern?: string; methodFilter?: string[]; statusFilter?: { min?: number; max?: number }; resourceTypes?: string[]; - /** Only requests whose start is at most N ms ago. */ sinceMs?: number; - /** Cap on the returned records. */ limit?: number; }; export type DrainResult = { readonly records: NetworkRecord[]; - /** Total matches before `limit` was applied. */ readonly total: number; - /** True if the ring buffer evicted at least one record since the last drain. */ readonly bufferOverflowed: boolean; }; @@ -70,15 +48,12 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => { const re = new RegExp(pattern.slice(1, -1)); return (url) => re.test(url); } catch { - // Fall through to substring match if the pattern is not a valid regex. } } return (url) => url.includes(pattern); }; export const createNetworkBuffer = (capacity = 500): NetworkBuffer => { - // Insertion-ordered map keyed by requestId. Map preserves insertion order in JS, - // so iteration gives us oldest-first eviction without a separate index. const records = new Map<string, NetworkRecord>(); let overflowed = false; @@ -99,7 +74,6 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => { const url = params?.request.url; const method = params?.request.method; if (!id || !url || !method) return; - // Re-fired requestWillBeSent for the same id (e.g. redirect) — refresh in place. records.delete(id); evictOldestIfFull(); const postData = params.request.postData; @@ -166,13 +140,12 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => { if (cutoff !== undefined && r.requestStartedMs < cutoff) continue; if (minStatus !== undefined && (r.status === undefined || r.status < minStatus)) continue; if (maxStatus !== undefined && (r.status === undefined || r.status > maxStatus)) continue; - // Clone so callers can mutate (e.g. attach body) without polluting the buffer. matched.push({ ...r }); } const total = matched.length; const limit = Math.min(filter.limit ?? 50, 500); - const limited = matched.slice(-limit); // most recent N + const limited = matched.slice(-limit); const bufferOverflowed = overflowed; overflowed = false; diff --git a/src/cdp/ownership.ts b/src/cdp/ownership.ts index 727b3d8..0a1a810 100644 --- a/src/cdp/ownership.ts +++ b/src/cdp/ownership.ts @@ -1,28 +1,12 @@ -/** - * Tracks which tab targetIds belong to this harness session. - * - * The harness attaches to the user's running Chrome, which is also full of - * the user's own tabs. Without an ownership boundary the agent could attach - * to (or close) personal tabs by accident. The registry is the single source - * of truth for "did this session open that tab?". - * - * Mutations notify a single change listener so the orchestrator can persist - * to disk via the existing BrowserState flow without the registry needing to - * know about the pi storage API. - */ export type OwnershipRegistry = { add(targetId: string): void; remove(targetId: string): void; has(targetId: string): boolean; list(): ReadonlyArray<string>; - /** Replace the full set — used on restoreState to re-hydrate from disk. */ replaceAll(ids: ReadonlyArray<string>): void; setHarnessWindow(targetId: string | undefined): void; harnessWindow(): string | undefined; - /** The real Chrome windowId of the dedicated harness window — the durable - * identity of "the window this session initialized", surviving seed-tab - * closure. Undefined until the window is created (or after teardown). */ setHarnessWindowId(windowId: number | undefined): void; harnessWindowId(): number | undefined; onChange(cb: () => void): void; diff --git a/src/cdp/session.ts b/src/cdp/session.ts index 80fec16..de3fb28 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -10,10 +10,6 @@ import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from import { attachTo } from "./attach"; import { getWindowId } from "./window"; -// Per-tab state. One TabSession exists per known targetId. -// Dialog, page-info dirty flag, and CDP buffers are per-tab — switching -// tabs preserves the previous tab's data in its TabSession instead of -// clearing it. type TabSession = { sessionId: string; targetId: string; @@ -21,12 +17,7 @@ type TabSession = { pageInfoDirty: boolean; networkBuffer: ReturnType<typeof createNetworkBuffer>; consoleBuffer: ReturnType<typeof createConsoleBuffer>; - // Stable element refs (e1, e2, …) → CDP backendNodeId, from the latest - // snapshot of this tab. Replaced wholesale on every snapshot — old refs are - // stale anyway, so this stays bounded (~50–60 KB worst case). refMap: Map<string, number>; - // Per-ref signature ("role|name|value") from the latest snapshot, used as the - // baseline for the post-mutation auto-diff. Replaced alongside refMap. refSig: Map<string, string>; }; @@ -37,11 +28,8 @@ export type CdpSession = { attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>; switchTo(targetId: string): Promise<Result<void, CdpError>>; current(): { readonly sessionId: string; readonly targetId: string } | null; - /** Replace the active tab's ref map and ref signatures (from a fresh snapshot). */ setRefMap(refMap: Map<string, number>, refSig: Map<string, string>): void; - /** Resolve a ref (e.g. "e12") to its backendNodeId on the active tab, or undefined if unknown/stale. */ resolveRef(ref: string): number | undefined; - /** The active tab's prior ref signatures — baseline for the post-mutation diff. */ refSignatures(): ReadonlyMap<string, string>; refMappings(): ReadonlyMap<string, number>; call<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>; @@ -53,15 +41,7 @@ export type CdpSession = { drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult; }; -/** - * Supplies the seed tab when attachFirstPage finds no owned tab to attach to. - * Injected by the client so that seeding goes through the target factory — - * which, with a profile pinned, opens the window inside that profile. Without - * it, attachFirstPage's own `Target.createTarget` would land in whichever - * profile currently has focus. - * - * The provider is responsible for its own ownership bookkeeping. - */ +// Injected so seeding goes through the target factory; attachFirstPage's own `Target.createTarget` would land in whichever profile has focus. export type SeedTargetProvider = () => Promise<Result<string, CdpError>>; export const createCdpSession = ( @@ -72,15 +52,11 @@ export const createCdpSession = ( let sessionId: string | null = null; let targetId: string | null = null; - // Per-tab tracking: maps targetId → TabSession and sessionId → targetId for - // event routing. Lazy: a TabSession is created on the first visit to a tab. const tabs = new Map<string, TabSession>(); const sessionIdToTargetId = new Map<string, string>(); let activeConsumer: Promise<void> = Promise.resolve(); - // Event → TabSession resolver. Uses sessionId from CDP events to find the - // correct TabSession, falling back to the current targetId when no sessionId. const resolveTab = (evSessionId?: string): TabSession | undefined => { const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId; return tid ? tabs.get(tid) : undefined; @@ -88,8 +64,6 @@ export const createCdpSession = ( const consumeEvents = async (): Promise<void> => { for await (const ev of transport.events()) { - // Filter: skip events from sessions we're not currently tracking. - // Target.targetDestroyed is browser-level (no sessionId) — always process. if (ev.method !== "Target.targetDestroyed") { if (ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) continue; } @@ -109,10 +83,7 @@ export const createCdpSession = ( : { type: "alert", message: "" }; break; } - // Page.javascriptDialogClosed is intentionally NOT cleared here — - // the dialog stays in the buffer until takeDialog() is called. - // This prevents fast dismiss flows from dropping a dialog the agent - // was about to read. (Fix for spec §7 predictability bug #2.) + // Intentionally NOT cleared on javascriptDialogClosed: a fast dismiss would otherwise drop a dialog the agent was about to read. case "Page.frameNavigated": case "Page.loadEventFired": { const tab = resolveTab(ev.sessionId); @@ -120,11 +91,6 @@ export const createCdpSession = ( break; } case "Target.targetCreated": { - // Adopt tabs opened BY a tab we already own (window.open, target=_blank, - // popups). Chrome sets openerId to the opener target; if that opener is - // owned, the child belongs to this session's window and must be - // controllable + cleaned up. Targets the user opens themselves have no - // owned opener, so they are never adopted. if (!ownership) break; const decoded = decodeEvent(ev.method, ev.params); if (!decoded.success) break; @@ -140,7 +106,6 @@ export const createCdpSession = ( if (!decoded.success || !decoded.data.targetId) break; const destroyed = decoded.data.targetId; ownership.remove(destroyed); - // Prune per-tab state for the destroyed target const tab = tabs.get(destroyed); if (tab) { sessionIdToTargetId.delete(tab.sessionId); @@ -186,10 +151,6 @@ export const createCdpSession = ( const restartConsumer = (): void => { activeConsumer = activeConsumer.then(() => consumeEvents()).catch((e: unknown) => { - // The .then() chain calls consumeEvents() which iterates the transport's - // events() AsyncIterable. The iterable resolves cleanly on close (returns - // {done:true}); any rejection here is an unexpected bug in the event - // handler, not a normal termination. Surface it on stderr so it's not lost. console.warn("[pi-browser-harness] CDP event consumer crashed:", e); }); }; @@ -200,26 +161,16 @@ export const createCdpSession = ( targetId = null; tabs.clear(); sessionIdToTargetId.clear(); - // Do NOT clear `dialog` here — same rationale as inside consumeEvents: - // the agent may have a pending takeDialog() call that should still see it. + // Do NOT clear `dialog` here: a pending takeDialog() must still see it. restartConsumer(); }); - // TODO(perf): the four enable calls are sequential here for predictability. - // Switching to Promise.all over a single WS pipelines the round-trips and - // saves ~3× on tab-switch latency. Defer until session.ts has tests. const enableDomains = async (sid: string): Promise<void> => { for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) { await transport.request(`${d}.enable`, {}, { sessionId: sid }); } }; - // The one place `unknown` ends for every CDP call this session makes — its - // own internal bookkeeping calls (attach/switch) and the public - // call/callOnTarget/callBrowser all funnel through here. A single - // transport-then-decode implementation means a future change (tracing, - // error wrapping, the short-circuit on failure) lands in one place, and a - // new internal call site can't forget to decode. const req = async <M extends CdpMethod>( method: M, params: ParamsOf<M> | undefined, @@ -236,14 +187,11 @@ export const createCdpSession = ( const session: CdpSession = { async attachFirstPage() { - // Subscribe to Target.* events so we can react to targetDestroyed. - // Best-effort: failing to enable discovery is not fatal for attach. await req("Target.setDiscoverTargets", { discover: true }, null); const targets = await req("Target.getTargets", {}, null); if (!targets.success) return targets; const allPages = targets.data.targetInfos.filter((t) => t.type === "page"); - // Reconcile the persisted ownership set against live targets — drop dead IDs. if (ownership) { const live = new Set(allPages.map((p) => p.targetId)); const survivors = ownership.list().filter((id) => live.has(id)); @@ -253,22 +201,12 @@ export const createCdpSession = ( const anchorId = survivors[0]; if (anchorId === undefined) { - // No owned tab survived — the harness window is gone (user closed it, - // or Chrome restarted and reassigned all ids). The persisted windowId - // is now meaningless and could even collide with one of the user's - // windows, so drop it. attachFirstPage will mint a fresh window below. ownership.setHarnessWindowId(undefined); } else { - // Self-heal ownership from the window itself. We anchor on a tab we - // KNOW we own (a survivor) and re-adopt only its live window-mates — - // popups/children from last session, tabs reopened in our window. - // Ownership is thus DERIVED from the real window, never from the bare - // persisted integer (which Chrome may reassign to a user window on - // restart — anchoring on a survivor makes that misfire impossible). const anchorWin = await getWindowId(session, anchorId); if (anchorWin.success) { const anchorWindowId = anchorWin.data; - ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id + ownership.setHarnessWindowId(anchorWindowId); const windows = await Promise.all( allPages.map((p) => p.targetId === anchorId @@ -286,7 +224,6 @@ export const createCdpSession = ( if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId); } } - // Prune per-tab state for pages that no longer exist const liveTargetIds = new Set(allPages.map((p) => p.targetId)); for (const tid of tabs.keys()) { if (!liveTargetIds.has(tid)) { @@ -296,17 +233,12 @@ export const createCdpSession = ( } } - // Prefer attaching to a tab this session already owns. Falls back to - // creating a fresh harness-owned tab in a dedicated window — never - // grabs the user's foreground tab. let pickTargetId: string | undefined; if (ownership) { const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id)); pickTargetId = ownedLive[0]; } if (!pickTargetId && createSeedTarget) { - // Delegated seeding (see SeedTargetProvider): the provider creates the - // window in the right profile and records ownership itself. const seeded = await createSeedTarget(); if (!seeded.success) return seeded; pickTargetId = seeded.data; @@ -322,8 +254,6 @@ export const createCdpSession = ( if (ownership) { ownership.setHarnessWindow(created.data.targetId); ownership.add(created.data.targetId); - // Capture the real Chrome windowId — the durable identity of the - // window this session initialized (survives seed-tab closure). const win = await getWindowId(session, created.data.targetId); if (win.success) ownership.setHarnessWindowId(win.data); } @@ -354,7 +284,6 @@ export const createCdpSession = ( const attached = await attachTo(session, tid); if (!attached.success) return attached; const attachedSessionId = attached.data; - // Reuse existing TabSession or create one on first visit const existing = tabs.get(tid); const tab: TabSession = existing ?? { sessionId: attachedSessionId, @@ -367,7 +296,6 @@ export const createCdpSession = ( refSig: new Map(), }; if (existing) { - // Each Target.attachToTarget produces a new sessionId — update it. sessionIdToTargetId.delete(existing.sessionId); existing.sessionId = attachedSessionId; } else { @@ -375,7 +303,6 @@ export const createCdpSession = ( } sessionIdToTargetId.set(attachedSessionId, tid); await enableDomains(attachedSessionId); - // Update global pointers to point at the new active tab sessionId = tab.sessionId; targetId = tid; return ok(undefined); diff --git a/src/cdp/target-factory.ts b/src/cdp/target-factory.ts index 718a8d0..62c46f1 100644 --- a/src/cdp/target-factory.ts +++ b/src/cdp/target-factory.ts @@ -1,31 +1,4 @@ -/** - * The single place harness tabs and windows are created. - * - * Every tab the harness opens must land in the pinned profile, so all creation - * paths (client.newTab, browser_open_urls, isolated tabs for web_search / - * read_page) funnel through here. A missed call site would silently act in - * another profile — with that profile's cookies and logins. - * - * Two mechanisms, chosen by whether a profile is pinned: - * - * unpinned — `Target.createTarget`, exactly as before: `newWindow: true` for - * a fresh dedicated window, `openerId` for siblings inside it. - * - * pinned — the window comes from a command-line launch into the profile - * (see profile/launch.ts), and further tabs come from - * `window.open(..., '_blank')` evaluated in an owned page with - * `userGesture: true`. That is the only CDP-reachable way to place - * a tab in a non-default browser context: createTarget rejects a - * foreign browserContextId outright, and `openerId` does NOT - * inherit the opener's context (verified — it lands in the - * focus-derived default context, in a different window). - * window.open keeps both the context and the window, recursively. - * - * Correlation: window.open returns no target id, so each spawn carries a unique - * `about:blank#pi-<uuid>` token and the new target is found by that token. - * Set-difference polling would be wrong here — isolated tabs run concurrently - * (browser_read_page is registered unserialized). - */ +// `window.open` with userGesture is the only CDP-reachable way into a non-default browser context: createTarget rejects a foreign browserContextId and `openerId` does not inherit the opener's context. import { randomUUID } from "node:crypto"; import type { BrowserClient } from "../client"; @@ -34,17 +7,11 @@ import { attachTo } from "./attach"; import { type CdpError, cdpError } from "./errors"; import { getWindowId } from "./window"; -/** How long to wait for a window.open-spawned target to appear. */ const SPAWN_DEADLINE_MS = 8_000; const SPAWN_POLL_MS = 100; export type HarnessWindow = { - /** Target id of the window's seed tab. */ readonly targetId: string; - /** - * True when this call created the window. The seed tab is then a blank tab - * the caller may use directly, instead of opening a second one. - */ readonly freshlyCreated: boolean; }; @@ -61,17 +28,12 @@ const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyAr return ok(r.data.targetInfos.filter((t) => t.type === "page")); }; -/** A CDP session id for driving `targetId`, reusing the active one when possible. */ const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => { const current = client.current(); if (current?.targetId === targetId) return ok(current.sessionId); return attachTo(client.session(), targetId); }; -/** - * Spawn a tab from `openerTargetId` via window.open, so it inherits the - * opener's profile and window, and return its target id. - */ const spawnTabViaOpener = async ( client: BrowserClient, openerTargetId: string, @@ -111,29 +73,13 @@ const spawnTabViaOpener = async ( return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`)); }; -/** - * In-flight window creation, per client. Isolated tabs run concurrently - * (browser_read_page is registered unserialized), so two of them starting at - * once would otherwise each find "no harness window" and create one — two blank - * windows before, and two launched profile windows now. Callers share the first - * call's result instead. - */ const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>(); -/** - * The harness window for this session, creating it when absent. - * - * Reuses a live owned tab when one exists — including after the user closed the - * original seed — so reconnects don't scatter windows. - */ export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => { const pending = inFlight.get(client); if (pending) { const shared = await pending; - // `freshlyCreated` is a claim on the seed tab, and only the caller that - // started the creation may hold it. Everyone waiting on the same window - // must open a tab of their own beside it, or two callers would drive the - // very same tab. + // `freshlyCreated` is a claim on the seed tab that only the caller which started the creation may hold, or two callers would drive the same tab. return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared; } const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client)); @@ -150,11 +96,7 @@ const ensureHarnessWindowUncoordinated = async ( const ownership = client.ownership(); const pinnedCtx = client.profileContextId(); - // A tab may only be reused when we can prove which profile it is in. With a - // pin set but no context resolved yet — a fresh connection, or a browser that - // restarted — the recorded tabs cannot be verified, so we seed a new window - // rather than risk adopting one from the wrong profile. (Sessions close their - // owned tabs on shutdown, so this is the normal, no-cost case.) + // With a pin set but no context resolved yet, a recorded tab's profile cannot be verified, so seed a new window rather than risk adopting one from the wrong profile. const canReuse = client.profilePin() === null || pinnedCtx !== undefined; const inPinnedProfile = (targetId: string): boolean => { if (!pinnedCtx) return true; @@ -181,7 +123,6 @@ const ensureHarnessWindowUncoordinated = async ( return ok({ targetId: seeded.data, freshlyCreated: true }); }; -/** Unpinned path: a fresh dedicated window, exactly as the harness always did. */ const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => { const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true }); if (!created.success) return created; @@ -194,10 +135,6 @@ const createDedicatedWindow = async (client: BrowserClient): Promise<Result<stri return ok(targetId); }; -/** - * Open a harness-owned tab beside `openerTargetId`, in the same window and — - * when a profile is pinned — the same profile. The tab is registered as owned. - */ export const openHarnessTab = async ( client: BrowserClient, openerTargetId: string, diff --git a/src/cdp/transport.ts b/src/cdp/transport.ts index f3bcd16..33b9412 100644 --- a/src/cdp/transport.ts +++ b/src/cdp/transport.ts @@ -15,8 +15,6 @@ export type CdpTransport = { params: Record<string, unknown>, opts?: { sessionId?: string | null; timeoutMs?: number }, ): Promise<Result<unknown, CdpError>>; - // Returns the current event queue's iterator. After close()+reconnect, callers - // must re-call events() — the old iterator will have received done:true. events(): AsyncIterable<CdpEvent>; state(): "open" | "closed" | "connecting"; onClose(cb: () => void): () => void; @@ -107,16 +105,13 @@ export const createCdpTransport = (): CdpTransport => { clearTimeout(timer); ws = null; cleanup("WebSocket closed"); - // If onopen already settled the connect promise, this settle is a no-op - // (guarded by `settled`). cleanup() above still runs unconditionally so - // pending requests are rejected and the event queue is rotated. settle(err(cdpError("transport_closed", "CDP WebSocket closed"))); }; }); }, close(): Promise<void> { if (ws) { - try { ws.close(1000, "Shutdown"); } catch { /* best effort */ } + try { ws.close(1000, "Shutdown"); } catch {} ws = null; } cleanup("close() called"); @@ -128,8 +123,7 @@ export const createCdpTransport = (): CdpTransport => { if (!ws || ws.readyState !== WebSocket.OPEN) { return Promise.resolve(err(cdpError("transport_closed", "Browser not connected. Is Chrome running?", method))); } - // Bind to a local const so the non-null is guaranteed even across microtasks - // between the readyState check and the send() call. + // Bind to a local const so the non-null holds across microtasks between the readyState check and send(). const sock = ws; const id = nextId++; const payload: Record<string, unknown> = { id, method, params }; @@ -138,11 +132,7 @@ export const createCdpTransport = (): CdpTransport => { return sendWithTimeout(pending, id, method, timeoutMs, "CDP", () => sock.send(json)); }, events(): AsyncIterable<CdpEvent> { - // Single-consumer: each connection's event stream may be iterated by - // exactly one for-await loop. Calling events() multiple times returns - // the same iterable; the second consumer will silently steal events - // from the first. After a reconnect, the previous iterator is ended - // and callers must re-call events() to receive new events. + // Single-consumer: a second for-await loop over this iterable silently steals events from the first. return queue.iter; }, state(): "open" | "closed" | "connecting" { diff --git a/src/cdp/window.ts b/src/cdp/window.ts index f16838c..b06f1ac 100644 --- a/src/cdp/window.ts +++ b/src/cdp/window.ts @@ -2,7 +2,6 @@ import { map, type Result } from "../util/result"; import type { CdpError } from "./errors"; import type { CdpSession } from "./session"; -/** Browser.getWindowForTarget, unwrapped to the windowId. */ export const getWindowId = async ( session: CdpSession, targetId: string, |
