aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-27 23:46:25 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commit753c2ad615fa9827e168be256e1a7c7384605855 (patch)
tree03d62eb1816f4ad0ad3b634b87c4dcdb6fdbd83c
parentd54d8e9e2dfbd31358b1f48e17338d74f5d270c0 (diff)
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>
-rw-r--r--src/cdp/console-buffer.ts37
-rw-r--r--src/cdp/events.ts170
-rw-r--r--src/cdp/network-buffer.ts53
-rw-r--r--src/cdp/session.ts139
-rw-r--r--test/cdp/events-test.ts94
5 files changed, 371 insertions, 122 deletions
diff --git a/src/cdp/console-buffer.ts b/src/cdp/console-buffer.ts
index 5ba7c87..f42312f 100644
--- a/src/cdp/console-buffer.ts
+++ b/src/cdp/console-buffer.ts
@@ -13,9 +13,11 @@
* 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";
export type ConsoleRecord = {
@@ -165,21 +167,9 @@ export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
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 @@ export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
},
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 ?? "");
diff --git a/src/cdp/events.ts b/src/cdp/events.ts
new file mode 100644
index 0000000..0c65069
--- /dev/null
+++ b/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>);
+};
diff --git a/src/cdp/network-buffer.ts b/src/cdp/network-buffer.ts
index f841719..c26ae73 100644
--- a/src/cdp/network-buffer.ts
+++ b/src/cdp/network-buffer.ts
@@ -10,10 +10,12 @@
* 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;
method: string;
@@ -91,29 +93,16 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
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 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
},
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 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
},
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 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
},
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);
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index 99e68b4..de1ff4b 100644
--- a/src/cdp/session.ts
+++ b/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";
@@ -29,6 +30,9 @@ type TabSession = {
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>>;
switchTo(targetId: string): Promise<Result<void, CdpError>>;
@@ -88,66 +92,93 @@ export const createCdpSession = (
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;
}
- }
- if (ev.method === "Target.targetDestroyed" && ownership) {
- const params = ev.params as { targetId?: string } | undefined;
- if (params?.targetId) {
- ownership.remove(params.targetId);
+ // 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;
+ }
+ 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;
}
}
};
diff --git a/test/cdp/events-test.ts b/test/cdp/events-test.ts
new file mode 100644
index 0000000..3699bce
--- /dev/null
+++ b/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);
+ });
+});