diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-27 23:46:25 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | 753c2ad615fa9827e168be256e1a7c7384605855 (patch) | |
| tree | 03d62eb1816f4ad0ad3b634b87c4dcdb6fdbd83c /src | |
| parent | d54d8e9e2dfbd31358b1f48e17338d74f5d270c0 (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>
Diffstat (limited to 'src')
| -rw-r--r-- | src/cdp/console-buffer.ts | 37 | ||||
| -rw-r--r-- | src/cdp/events.ts | 170 | ||||
| -rw-r--r-- | src/cdp/network-buffer.ts | 53 | ||||
| -rw-r--r-- | src/cdp/session.ts | 139 |
4 files changed, 277 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; } } }; |
