all repos — sz-chrome-remote-cdp @ 753c2ad615fa9827e168be256e1a7c7384605855

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

refactor(cdp): validate event params against a typed table

Malformed or unknown events are now dropped rather than hand-narrowed;
consumers get typed params. Unknown methods are ignored, since the
transport delivers every event Chrome emits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
amankumarsingh77 amankumarsingh7702@gmail.com
Mon, 27 Jul 2026 23:46:25 +0530
commit

753c2ad615fa9827e168be256e1a7c7384605855

parent

d54d8e9e2dfbd31358b1f48e17338d74f5d270c0

M src/cdp/console-buffer.ts → src/cdp/console-buffer.ts

@@ -13,8 +13,10 @@ * `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. * - * Pure module (no CDP dependency) — joining/filtering logic stays trivially testable. + * Joining/filtering logic stays trivially testable (no transport dependency). */ + +import { decodeEvent } from "./events"; export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";

@@ -165,21 +167,9 @@ };

return { ingestConsoleApi(p) { - // CDP boundary cast: Runtime.consoleAPICalled - const params = p as - | { - type?: string; - args?: ReadonlyArray<{ - type?: string; - subtype?: string; - value?: unknown; - description?: string; - unserializableValue?: string; - }>; - stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> }; - } - | undefined; - if (!params) return; + const decoded = decodeEvent("Runtime.consoleAPICalled", p); + if (!decoded.success) return; + const params = decoded.data; const level = mapConsoleApiLevel(params.type); const argTexts = (params.args ?? []).map((a) => truncateArg(stringifyRemoteObject(a))); const text = argTexts.join(" ");

@@ -199,19 +189,8 @@ insert(rec);

}, ingestLogEntry(p) { - // CDP boundary cast: Log.entryAdded - const params = p as - | { - entry?: { - level?: string; - text?: string; - url?: string; - lineNumber?: number; - stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> }; - }; - } - | undefined; - const entry = params?.entry; + const decoded = decodeEvent("Log.entryAdded", p); + const entry = decoded.success ? decoded.data.entry : undefined; if (!entry) return; const level = mapLogEntryLevel(entry.level); const text = truncateArg(entry.text ?? "");
A src/cdp/events.ts

@@ -0,0 +1,170 @@

+import { Type, type Static, type TSchema } from "typebox"; +import { Compile } from "typebox/compile"; +import { type Result, err, ok } from "../util/result"; +import { type CdpError, cdpError } from "./errors"; + +const ev = <P extends TSchema>(params: P) => ({ params, validate: Compile(params) }); + +const open = { additionalProperties: true } as const; + +const Anything = Type.Object({}, open); + +const StackTrace = Type.Object( + { + callFrames: Type.Optional( + Type.Array( + Type.Object( + { + url: Type.Optional(Type.String()), + lineNumber: Type.Optional(Type.Number()), + functionName: Type.Optional(Type.String()), + }, + open, + ), + ), + ), + }, + open, +); + +const ConsoleArg = Type.Object( + { + type: Type.Optional(Type.String()), + value: Type.Optional(Type.Unknown()), + description: Type.Optional(Type.String()), + unserializableValue: Type.Optional(Type.String()), + }, + open, +); + +export const EVENTS = { + "Inspector.detached": ev(Anything), + + "Page.javascriptDialogOpening": ev( + Type.Object( + { + type: Type.String(), + message: Type.String(), + defaultPrompt: Type.Optional(Type.String()), + }, + open, + ), + ), + "Page.loadEventFired": ev(Anything), + "Page.frameNavigated": ev(Anything), + + "Runtime.consoleAPICalled": ev( + Type.Object( + { + type: Type.Optional(Type.String()), + args: Type.Optional(Type.Array(ConsoleArg)), + stackTrace: Type.Optional(StackTrace), + }, + open, + ), + ), + + "Log.entryAdded": ev( + Type.Object( + { + entry: Type.Object( + { + level: Type.Optional(Type.String()), + text: Type.Optional(Type.String()), + url: Type.Optional(Type.String()), + lineNumber: Type.Optional(Type.Number()), + stackTrace: Type.Optional(StackTrace), + }, + open, + ), + }, + open, + ), + ), + + "Network.requestWillBeSent": ev( + Type.Object( + { + requestId: Type.String(), + request: Type.Object( + { + url: Type.String(), + method: Type.String(), + postData: Type.Optional(Type.String()), + }, + open, + ), + type: Type.Optional(Type.String()), + }, + open, + ), + ), + "Network.responseReceived": ev( + Type.Object( + { + requestId: Type.String(), + response: Type.Optional( + Type.Object( + { + status: Type.Optional(Type.Number()), + statusText: Type.Optional(Type.String()), + mimeType: Type.Optional(Type.String()), + }, + open, + ), + ), + type: Type.Optional(Type.String()), + }, + open, + ), + ), + "Network.loadingFinished": ev( + Type.Object( + { requestId: Type.String(), encodedDataLength: Type.Optional(Type.Number()) }, + open, + ), + ), + "Network.loadingFailed": ev( + Type.Object( + { requestId: Type.String(), errorText: Type.Optional(Type.String()) }, + open, + ), + ), + + "Target.targetCreated": ev( + Type.Object( + { + targetInfo: Type.Object( + { + targetId: Type.String(), + type: Type.String(), + openerId: Type.Optional(Type.String()), + }, + open, + ), + }, + open, + ), + ), + "Target.targetDestroyed": ev(Type.Object({ targetId: Type.String() }, open)), +}; + +export type CdpEventName = keyof typeof EVENTS; +export type EventParamsOf<E extends CdpEventName> = Static<(typeof EVENTS)[E]["params"]>; + +export const isKnownEvent = (name: string): name is CdpEventName => Object.hasOwn(EVENTS, name); + +export const decodeEvent = <E extends CdpEventName>( + name: E, + raw: unknown, +): Result<EventParamsOf<E>, CdpError> => { + const spec = EVENTS[name]; + if (spec === undefined) { + return err(cdpError("invalid_response", "unknown CDP event", name)); + } + if (!spec.validate.Check(raw)) { + const why = spec.validate.Errors(raw).map((e) => e.message).join("; "); + return err(cdpError("invalid_response", why, name)); + } + return ok(raw as EventParamsOf<E>); +};
M src/cdp/network-buffer.ts → src/cdp/network-buffer.ts

@@ -10,9 +10,11 @@ * 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 is pure (no CDP dependency) so the joining/filtering logic stays + * This module has no transport dependency, so the joining/filtering logic stays * trivially testable in isolation. */ + +import { decodeEvent } from "./events"; export type NetworkRecord = { requestId: string;

@@ -91,29 +93,16 @@ };

return { ingestRequestWillBeSent(p) { - // CDP boundary cast: Network.requestWillBeSent - const params = p as - | { - requestId?: string; - request?: { - url?: string; - method?: string; - postData?: string; - headers?: Record<string, string>; - }; - type?: string; - timestamp?: number; - wallTime?: number; - } - | undefined; + const decoded = decodeEvent("Network.requestWillBeSent", p); + const params = decoded.success ? decoded.data : undefined; const id = params?.requestId; - const url = params?.request?.url; - const method = params?.request?.method; + 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; + const postData = params.request.postData; records.set(id, { requestId: id, method,

@@ -125,18 +114,8 @@ });

}, ingestResponseReceived(p) { - // CDP boundary cast: Network.responseReceived - const params = p as - | { - requestId?: string; - response?: { - status?: number; - statusText?: string; - mimeType?: string; - }; - type?: string; - } - | undefined; + const decoded = decodeEvent("Network.responseReceived", p); + const params = decoded.success ? decoded.data : undefined; const id = params?.requestId; if (!id) return; const r = records.get(id);

@@ -149,10 +128,8 @@ if (params?.type !== undefined) r.type = params.type;

}, ingestLoadingFinished(p) { - // CDP boundary cast: Network.loadingFinished - const params = p as - | { requestId?: string; encodedDataLength?: number } - | undefined; + const decoded = decodeEvent("Network.loadingFinished", p); + const params = decoded.success ? decoded.data : undefined; const id = params?.requestId; if (!id) return; const r = records.get(id);

@@ -162,10 +139,8 @@ r.durationMs = Date.now() - r.requestStartedMs;

}, ingestLoadingFailed(p) { - // CDP boundary cast: Network.loadingFailed - const params = p as - | { requestId?: string; errorText?: string; canceled?: boolean } - | undefined; + const decoded = decodeEvent("Network.loadingFailed", p); + const params = decoded.success ? decoded.data : undefined; const id = params?.requestId; if (!id) return; const r = records.get(id);
M src/cdp/session.ts → src/cdp/session.ts

@@ -4,6 +4,7 @@ import type { DialogInfo } from "./types";

import type { OwnershipRegistry } from "./ownership"; import type { CdpTransport } from "./transport"; import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands"; +import { decodeEvent } from "./events"; import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer"; import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer"; import { attachTo } from "./attach";

@@ -28,6 +29,9 @@ // 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>; }; + +const dialogType = (raw: string): DialogInfo["type"] => + raw === "confirm" || raw === "prompt" || raw === "beforeunload" ? raw : "alert"; export type CdpSession = { attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;

@@ -88,66 +92,93 @@ // Target.targetDestroyed is browser-level (no sessionId) — always process.

if (ev.method !== "Target.targetDestroyed") { if (ev.sessionId && ev.sessionId !== sessionId) continue; } - if (ev.method === "Page.javascriptDialogOpening") { - const tab = targetId ? tabs.get(targetId) : undefined; - if (!tab) continue; - const params = ev.params as Partial<DialogInfo> | undefined; - tab.dialog = { - type: (params?.type as DialogInfo["type"]) ?? "alert", - message: params?.message ?? "", - ...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}), - }; - continue; - } - // 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.) - if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") { - const tab = targetId ? tabs.get(targetId) : undefined; - if (tab) tab.pageInfoDirty = true; - } - if (ev.method === "Target.targetCreated" && ownership) { - // 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. - const info = (ev.params as { targetInfo?: { targetId?: string; type?: string; openerId?: string } } | undefined)?.targetInfo; - if (info?.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) { - ownership.add(info.targetId); + switch (ev.method) { + case "Page.javascriptDialogOpening": { + const tab = targetId ? tabs.get(targetId) : undefined; + if (!tab) break; + const decoded = decodeEvent(ev.method, ev.params); + tab.dialog = decoded.success + ? { + type: dialogType(decoded.data.type), + message: decoded.data.message, + ...(decoded.data.defaultPrompt !== undefined + ? { defaultPrompt: decoded.data.defaultPrompt } + : {}), + } + : { 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.) + case "Page.frameNavigated": + case "Page.loadEventFired": { + const tab = targetId ? tabs.get(targetId) : undefined; + if (tab) tab.pageInfoDirty = true; + 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; + const info = decoded.data.targetInfo; + if (info.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) { + ownership.add(info.targetId); + } + break; } - } - if (ev.method === "Target.targetDestroyed" && ownership) { - const params = ev.params as { targetId?: string } | undefined; - if (params?.targetId) { - ownership.remove(params.targetId); + case "Target.targetDestroyed": { + if (!ownership) break; + const decoded = decodeEvent(ev.method, ev.params); + 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(params.targetId); + const tab = tabs.get(destroyed); if (tab) { sessionIdToTargetId.delete(tab.sessionId); - tabs.delete(params.targetId); + tabs.delete(destroyed); } + break; } - } - if (ev.method === "Network.requestWillBeSent") { - const tab = resolveTab(ev.sessionId); - if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params); - } else if (ev.method === "Network.responseReceived") { - const tab = resolveTab(ev.sessionId); - if (tab) tab.networkBuffer.ingestResponseReceived(ev.params); - } else if (ev.method === "Network.loadingFinished") { - const tab = resolveTab(ev.sessionId); - if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params); - } else if (ev.method === "Network.loadingFailed") { - const tab = resolveTab(ev.sessionId); - if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params); - } else if (ev.method === "Runtime.consoleAPICalled") { - const tab = resolveTab(ev.sessionId); - if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params); - } else if (ev.method === "Log.entryAdded") { - const tab = resolveTab(ev.sessionId); - if (tab) tab.consoleBuffer.ingestLogEntry(ev.params); + case "Network.requestWillBeSent": { + const tab = resolveTab(ev.sessionId); + if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params); + break; + } + case "Network.responseReceived": { + const tab = resolveTab(ev.sessionId); + if (tab) tab.networkBuffer.ingestResponseReceived(ev.params); + break; + } + case "Network.loadingFinished": { + const tab = resolveTab(ev.sessionId); + if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params); + break; + } + case "Network.loadingFailed": { + const tab = resolveTab(ev.sessionId); + if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params); + break; + } + case "Runtime.consoleAPICalled": { + const tab = resolveTab(ev.sessionId); + if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params); + break; + } + case "Log.entryAdded": { + const tab = resolveTab(ev.sessionId); + if (tab) tab.consoleBuffer.ingestLogEntry(ev.params); + break; + } + default: + break; } } };
A test/cdp/events-test.ts

@@ -0,0 +1,94 @@

+import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { EVENTS, decodeEvent, isKnownEvent } from "../../src/cdp/events"; +import { createConsoleBuffer } from "../../src/cdp/console-buffer"; +import { createNetworkBuffer } from "../../src/cdp/network-buffer"; + +describe("cdp events", () => { + test("decodes a dialog-opening event", () => { + const r = decodeEvent("Page.javascriptDialogOpening", { type: "confirm", message: "Sure?" }); + assert.equal(r.success, true); + if (r.success) assert.equal(r.data.type, "confirm"); + }); + + test("rejects a malformed event payload", () => { + const r = decodeEvent("Page.javascriptDialogOpening", { type: 5 }); + assert.equal(r.success, false); + if (!r.success) { + assert.equal(r.error.kind, "invalid_response"); + assert.equal(r.error.method, "Page.javascriptDialogOpening"); + } + }); + + test("tolerates unknown extra fields Chrome may add", () => { + const r = decodeEvent("Target.targetDestroyed", { targetId: "t1", extra: true }); + assert.equal(r.success, true); + }); + + test("isKnownEvent gates unrecognised methods", () => { + assert.equal(isKnownEvent("Page.loadEventFired"), true); + assert.equal(isKnownEvent("Fetch.requestPaused"), false); + }); + + test("the table covers exactly the events the codebase dispatches on", () => { + assert.deepEqual(Object.keys(EVENTS).sort(), [ + "Inspector.detached", + "Log.entryAdded", + "Network.loadingFailed", + "Network.loadingFinished", + "Network.requestWillBeSent", + "Network.responseReceived", + "Page.frameNavigated", + "Page.javascriptDialogOpening", + "Page.loadEventFired", + "Runtime.consoleAPICalled", + "Target.targetCreated", + "Target.targetDestroyed", + ]); + }); + + test("a non-object payload decodes to an error rather than throwing", () => { + assert.equal(decodeEvent("Target.targetDestroyed", null).success, false); + assert.equal(decodeEvent("Log.entryAdded", undefined).success, false); + }); + + test("targetCreated keeps openerId optional", () => { + const r = decodeEvent("Target.targetCreated", { + targetInfo: { targetId: "t1", type: "page", title: "x", url: "about:blank" }, + }); + assert.equal(r.success, true); + if (r.success) assert.equal(r.data.targetInfo.openerId, undefined); + }); +}); + +describe("cdp events dropped payloads", () => { + test("console buffer ignores a malformed Log.entryAdded without throwing", () => { + const buf = createConsoleBuffer(); + buf.ingestLogEntry({ entry: { level: "error", text: 42 } }); + assert.deepEqual(buf.drain({}).records, []); + }); + + test("console buffer still records a well-formed Log.entryAdded", () => { + const buf = createConsoleBuffer(); + buf.ingestLogEntry({ entry: { level: "error", text: "boom" } }); + const drained = buf.drain({}); + assert.equal(drained.records.length, 1); + assert.equal(drained.records[0]?.text, "boom"); + assert.equal(drained.records[0]?.level, "error"); + }); + + test("network buffer ignores a malformed requestWillBeSent without throwing", () => { + const buf = createNetworkBuffer(); + buf.ingestRequestWillBeSent({ requestId: "r1", request: { url: 7, method: "GET" } }); + assert.deepEqual(buf.drain({}).records, []); + }); + + test("network buffer keeps an existing record when a later event is malformed", () => { + const buf = createNetworkBuffer(); + buf.ingestRequestWillBeSent({ requestId: "r1", request: { url: "https://a/", method: "GET" } }); + buf.ingestResponseReceived({ requestId: "r1", response: { status: "200" } }); + const drained = buf.drain({}); + assert.equal(drained.records.length, 1); + assert.equal(drained.records[0]?.status, undefined); + }); +});