diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-28 01:19:58 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | f9dcdf7f6dcdf2979693a9f179eda2b9995d57ae (patch) | |
| tree | 763ca6f879a693f56572ab256d33769d6d6bb0ad /src | |
| parent | b9c77bb6f9b8b83574489344ef6efd207a611e98 (diff) | |
refactor: close the remaining unguarded type assertions
Types the event-queue iterator's TReturn as undefined so the terminal
result needs no cast; declares the isError field the pi runtime reads by
duck-typing; binds socket to a local const in the daemon transport;
replaces the @ts-ignore on the optional sharp import with a non-literal
specifier so src/ still typechecks where sharp was not installed.
Adds src/util/guards.ts as the single home for narrowing over unknown —
isRecord plus scalar/array readers — and converts the two private
isObject copies in cdp/types.ts and daemon/protocol.ts along with every
inline `as Record<string, unknown>` in domains/js.ts and domains/render.ts.
Gives Accessibility.getFullAXTree a real result schema, closing the last
unvalidated CDP boundary: only nodeId is required, every object carries
additionalProperties, and each AX value stays Unknown, so the tree
decodes as a whole or fails as a whole rather than being filtered.
Guards the five tool-result `details` readers, the daemon attach
response, the persisted session state, the browser-namespace flag, and
the fs errno narrowing, each feeding an already-present failure path.
The daemon-transport socket binding is not a bug fix: send() runs
synchronously inside sendWithTimeout's promise executor, so socket cannot
be reassigned between the check and the write. It removes the assertion
and makes the write immune to a later refactor that adds an await.
Two assertions survive by design and are documented in the task report:
the never->unknown re-widening in util/tool.ts, which the type-erased
tool registry makes unavoidable, and the AsyncFunction constructor lookup
in domains/js.ts, which JavaScript exposes no typed binding for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/cdp/commands.ts | 29 | ||||
| -rw-r--r-- | src/cdp/daemon-transport.ts | 21 | ||||
| -rw-r--r-- | src/cdp/discovery.ts | 3 | ||||
| -rw-r--r-- | src/cdp/event-queue.ts | 13 | ||||
| -rw-r--r-- | src/cdp/session.ts | 4 | ||||
| -rw-r--r-- | src/cdp/types.ts | 9 | ||||
| -rw-r--r-- | src/client.ts | 3 | ||||
| -rw-r--r-- | src/daemon/bridge.ts | 16 | ||||
| -rw-r--r-- | src/daemon/protocol.ts | 9 | ||||
| -rw-r--r-- | src/domains/console.ts | 47 | ||||
| -rw-r--r-- | src/domains/js.ts | 38 | ||||
| -rw-r--r-- | src/domains/network.ts | 54 | ||||
| -rw-r--r-- | src/domains/ref-resolve.ts | 4 | ||||
| -rw-r--r-- | src/domains/render.ts | 14 | ||||
| -rw-r--r-- | src/domains/screenshot.ts | 8 | ||||
| -rw-r--r-- | src/domains/snapshot.ts | 35 | ||||
| -rw-r--r-- | src/index.ts | 3 | ||||
| -rw-r--r-- | src/state.ts | 19 | ||||
| -rw-r--r-- | src/util/guards.ts | 30 | ||||
| -rw-r--r-- | src/util/sharp-shim.ts | 20 | ||||
| -rw-r--r-- | src/util/tool.ts | 27 |
21 files changed, 285 insertions, 121 deletions
diff --git a/src/cdp/commands.ts b/src/cdp/commands.ts index 95c8be2..e6d744d 100644 --- a/src/cdp/commands.ts +++ b/src/cdp/commands.ts @@ -50,6 +50,33 @@ const ExceptionDetails = Type.Object( { additionalProperties: true }, ); +const AxValue = Type.Object({ value: Type.Optional(Type.Unknown()) }, { additionalProperties: true }); + +const AxNode = Type.Object( + { + nodeId: Type.String(), + parentId: Type.Optional(Type.String()), + childIds: Type.Optional(Type.Array(Type.String())), + ignored: Type.Optional(Type.Boolean()), + role: Type.Optional(AxValue), + name: Type.Optional(AxValue), + value: Type.Optional(AxValue), + description: Type.Optional(AxValue), + properties: Type.Optional( + Type.Array( + Type.Object( + { name: Type.Optional(Type.String()), value: Type.Optional(AxValue) }, + { additionalProperties: true }, + ), + ), + ), + backendDOMNodeId: Type.Optional(Type.Number()), + }, + { additionalProperties: true }, +); + +export type AxNodeResult = Static<typeof AxNode>; + export const COMMANDS = { // --- Target --- "Target.attachToTarget": cmd( @@ -211,7 +238,7 @@ export const COMMANDS = { "Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty), "Accessibility.getFullAXTree": cmd( Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }), - Type.Object({ nodes: Type.Array(Type.Unknown()) }), + Type.Object({ nodes: Type.Array(AxNode) }), ), }; diff --git a/src/cdp/daemon-transport.ts b/src/cdp/daemon-transport.ts index 4b2fb78..819e581 100644 --- a/src/cdp/daemon-transport.ts +++ b/src/cdp/daemon-transport.ts @@ -67,21 +67,23 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { resolve(r); }; + let sock: Socket; try { - socket = createConnection(DAEMON_SOCKET_PATH); + sock = createConnection(DAEMON_SOCKET_PATH); } catch (e) { settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e)))); return; } + socket = sock; const connectTimer = setTimeout(() => { cleanup("Connection timeout"); settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`))); }, timeoutMs); - socket.on("connect", () => { + sock.on("connect", () => { clearTimeout(connectTimer); - rl = createInterface({ input: socket!, crlfDelay: Infinity }); + rl = createInterface({ input: sock, crlfDelay: Infinity }); rl.on("line", (line: string) => { const msg = deserialize(line); @@ -130,11 +132,11 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { } }); - socket!.on("error", () => { + sock.on("error", () => { // Errors are surfaced via 'close' }); - socket!.on("close", () => { + sock.on("close", () => { clearTimeout(connectTimer); cleanup("Daemon socket closed"); // If connect hasn't settled yet, settle with error @@ -143,10 +145,10 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { // Send registration const regMsg: WireControl = { type: "control", action: "register", clientId }; - socket!.write(serialize(regMsg) + "\n"); + sock.write(serialize(regMsg) + "\n"); }); - socket.on("error", (e: NodeJS.ErrnoException) => { + sock.on("error", (e: NodeJS.ErrnoException) => { clearTimeout(connectTimer); const msg = e.code === "ENOENT" ? "Daemon not running — socket not found" @@ -176,7 +178,8 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { const timeoutMs = opts?.timeoutMs ?? 15_000; const sessionId = opts?.sessionId ?? null; - if (!socket || socket.destroyed || !registered) { + const sock = socket; + if (sock === null || sock.destroyed || !registered) { return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method))); } @@ -189,7 +192,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => { ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}), }; - return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => socket!.write(serialize(req) + "\n")); + return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n")); }; // ── Events ───────────────────────────────────────────────────────────── diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts index b4be31c..dc998b5 100644 --- a/src/cdp/discovery.ts +++ b/src/cdp/discovery.ts @@ -8,6 +8,7 @@ import { parseJson } from "../schemas/parse"; import { type Result, err, ok } from "../util/result"; import { userDataDirCandidates } from "../profile/paths"; import { type CdpError, cdpError } from "./errors"; +import { errnoCode } from "../util/guards"; const PORT_PROBE_DEADLINE_MS = 30_000; const PORT_PROBE_INTERVAL_MS = 1_000; @@ -149,7 +150,7 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> // 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. - const code = (e as NodeJS.ErrnoException).code; + const code = errnoCode(e); if (code === "ENOENT" || code === undefined) continue; if (code === "EPERM" || code === "EACCES") { readErrors.push(`${portFile}: ${code}`); diff --git a/src/cdp/event-queue.ts b/src/cdp/event-queue.ts index 281fb3f..87f5551 100644 --- a/src/cdp/event-queue.ts +++ b/src/cdp/event-queue.ts @@ -30,7 +30,7 @@ export type EventQueue = { export const makeEventQueue = (): EventQueue => { const buf: CdpEvent[] = []; - const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = []; + const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = []; let ended = false; return { push(e) { @@ -41,19 +41,16 @@ export const makeEventQueue = (): EventQueue => { }, end() { ended = true; - // value: undefined as unknown as CdpEvent is the correct iterator-protocol - // pattern: when done=true the value field is conventionally undefined but - // the TS type still requires CdpEvent. - for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true }); + for (const w of waiters.splice(0)) w({ value: undefined, done: true }); }, iter: { - [Symbol.asyncIterator]() { + [Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> { return { - next: (): Promise<IteratorResult<CdpEvent>> => + next: (): Promise<IteratorResult<CdpEvent, undefined>> => new Promise((resolve) => { const next = buf.shift(); if (next) resolve({ value: next, done: false }); - else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true }); + else if (ended) resolve({ value: undefined, done: true }); else waiters.push(resolve); }), }; diff --git a/src/cdp/session.ts b/src/cdp/session.ts index de1ff4b..bf423e1 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -250,7 +250,8 @@ export const createCdpSession = ( const hw = ownership.harnessWindow(); if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined); - if (survivors.length === 0) { + 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 @@ -263,7 +264,6 @@ export const createCdpSession = ( // 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 anchorId = survivors[0]!; const anchorWin = await getWindowId(session, anchorId); if (anchorWin.success) { const anchorWindowId = anchorWin.data; diff --git a/src/cdp/types.ts b/src/cdp/types.ts index d57255f..33ab202 100644 --- a/src/cdp/types.ts +++ b/src/cdp/types.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../util/guards"; + export type CdpEvent = { readonly method: string; readonly params: unknown; @@ -51,11 +53,8 @@ const INTERNAL_PREFIXES: ReadonlyArray<string> = [ export const isInternalUrl = (url: string): boolean => INTERNAL_PREFIXES.some((p) => url.startsWith(p)); -const isObject = (v: unknown): v is Readonly<Record<string, unknown>> => - typeof v === "object" && v !== null && !Array.isArray(v); - export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => { - if (!isObject(v)) return false; + if (!isRecord(v)) return false; const id = v["id"]; if (id !== undefined && typeof id !== "number") return false; const method = v["method"]; @@ -64,7 +63,7 @@ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => { if (sessionId !== undefined && typeof sessionId !== "string") return false; const errVal = v["error"]; if (errVal !== undefined) { - if (!isObject(errVal)) return false; + if (!isRecord(errVal)) return false; if (typeof errVal["message"] !== "string") return false; const code = errVal["code"]; if (code !== undefined && typeof code !== "number") return false; diff --git a/src/client.ts b/src/client.ts index ad35826..4937447 100644 --- a/src/client.ts +++ b/src/client.ts @@ -280,8 +280,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => { const r = await session.callBrowser("Target.getTargets"); if (!r.success) return r; - const data = r.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; title: string; url: string }> }; - const tabs = data.targetInfos + const tabs = r.data.targetInfos .filter((t) => t.type === "page") .filter((t) => includeInternal || !t.url.startsWith("chrome://")) .map((t): TabInfo => ({ diff --git a/src/daemon/bridge.ts b/src/daemon/bridge.ts index de6dec7..3f8b604 100644 --- a/src/daemon/bridge.ts +++ b/src/daemon/bridge.ts @@ -16,6 +16,7 @@ import { isCdpRawMessage } from "../cdp/types"; import type { CdpRawMessage } from "../cdp/types"; import type { WireRequest, WireResponse, WireEvent } from "./protocol"; import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol"; +import { asString, isRecord } from "../util/guards"; // ── Types ────────────────────────────────────────────────────────────────────── @@ -179,9 +180,9 @@ export const createCdpBridge = (): CdpBridge => { } else { // Track session ownership for attach responses if (cb.isAttach && msg.result) { - const result = msg.result as { sessionId?: string }; - if (result.sessionId) { - router.record(cb.clientId, result.sessionId); + const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined; + if (sessionId) { + router.record(cb.clientId, sessionId); } } cb.send(cb.clientId, { type: "response", id: localId, result: msg.result }); @@ -238,12 +239,15 @@ export const createCdpBridge = (): CdpBridge => { if (ws && ws.readyState === WebSocket.OPEN) return; // Resolve URL (discover if not cached, or clear cache on retry) - let url = wsUrl; - if (!url || reconnectAttempt > 0) { + const cachedUrl = wsUrl; + let url: string; + if (cachedUrl === null || cachedUrl === "" || reconnectAttempt > 0) { const d = await discoverWsUrl(); if (!d.success) { scheduleRetry(); return; } url = d.data; wsUrl = url; + } else { + url = cachedUrl; } const settledPromise = new Promise<void>((settle) => { @@ -256,7 +260,7 @@ export const createCdpBridge = (): CdpBridge => { let sock: WebSocket; try { - sock = new WebSocket(url!, { perMessageDeflate: false }); + sock = new WebSocket(url, { perMessageDeflate: false }); } catch { scheduleRetry(); return; diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 4fa4a2a..997a6c8 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -10,6 +10,8 @@ * - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line */ +import { isRecord } from "../util/guards"; + // ── Message types ────────────────────────────────────────────────────────────── /** A CDP command sent from a pi client to the daemon. */ @@ -60,15 +62,12 @@ export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl; // ── Type guard ───────────────────────────────────────────────────────────────── -const isObject = (v: unknown): v is Readonly<Record<string, unknown>> => - typeof v === "object" && v !== null && !Array.isArray(v); - /** * Validates that an unknown parsed-JSON value is a well-formed WireMessage. * Does NOT validate the full CDP semantics — only structural shape. */ export const isWireMessage = (v: unknown): v is WireMessage => { - if (!isObject(v)) return false; + if (!isRecord(v)) return false; const t = v["type"]; if (typeof t !== "string") return false; @@ -84,7 +83,7 @@ export const isWireMessage = (v: unknown): v is WireMessage => { if (typeof v["id"] !== "number") return false; const errVal = v["error"]; if (errVal !== undefined) { - if (!isObject(errVal)) return false; + if (!isRecord(errVal)) return false; if (typeof errVal["message"] !== "string") return false; if (typeof errVal["code"] !== "number") return false; } diff --git a/src/domains/console.ts b/src/domains/console.ts index 9696cd9..c38da2a 100644 --- a/src/domains/console.ts +++ b/src/domains/console.ts @@ -5,6 +5,7 @@ import { type Result, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer"; +import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards"; const ConsoleArgs = Type.Object({ levels: Type.Optional( @@ -106,6 +107,50 @@ type ConsoleDetails = { nextCursor?: number; }; +const CONSOLE_LEVELS: ReadonlyArray<ConsoleLevel> = ["log", "info", "warn", "error", "debug"]; + +const asConsoleLevel = (v: unknown): ConsoleLevel | undefined => + CONSOLE_LEVELS.find((l) => l === v); + +const asConsoleRecord = (v: unknown): ConsoleRecord | undefined => { + const o = asRecord(v); + if (o === undefined) return undefined; + const level = asConsoleLevel(o["level"]); + const text = asString(o["text"]); + if (level === undefined || text === undefined) return undefined; + const url = asString(o["url"]); + const lineNumber = asNumber(o["lineNumber"]); + const stackTrace = asString(o["stackTrace"]); + return { + seq: asNumber(o["seq"]) ?? 0, + timestampMs: asNumber(o["timestampMs"]) ?? 0, + level, + text, + source: o["source"] === "log-entry" ? "log-entry" : "console-api", + ...(url !== undefined ? { url } : {}), + ...(lineNumber !== undefined ? { lineNumber } : {}), + ...(stackTrace !== undefined ? { stackTrace } : {}), + }; +}; + +const asConsoleDetails = (v: unknown): ConsoleDetails | undefined => { + const o = asRecord(v); + if (o === undefined) return undefined; + const records = asArrayOf(o["records"], asConsoleRecord); + if (records === undefined) return undefined; + const fullOutputPath = asString(o["fullOutputPath"]); + const nextCursor = asNumber(o["nextCursor"]); + return { + total: asNumber(o["total"]) ?? records.length, + returned: asNumber(o["returned"]) ?? records.length, + bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false, + truncated: asBoolean(o["truncated"]) ?? false, + records, + ...(fullOutputPath !== undefined ? { fullOutputPath } : {}), + ...(nextCursor !== undefined ? { nextCursor } : {}), + }; +}; + export const consoleTool = defineBrowserTool({ name: "browser_console", label: "Browser Console", @@ -156,7 +201,7 @@ export const consoleTool = defineBrowserTool({ }, renderResult(result, expanded, theme) { - const details = result.details as ConsoleDetails | undefined; + const details = asConsoleDetails(result.details); if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0); const md = renderConsoleMarkdown(details.records, { diff --git a/src/domains/js.ts b/src/domains/js.ts index 61dbe53..6d9ace0 100644 --- a/src/domains/js.ts +++ b/src/domains/js.ts @@ -8,6 +8,7 @@ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; +import { asNumber, asRecord, asString, isRecord } from "../util/guards"; const COMPACT_PREVIEW_BYTES = 120; @@ -80,10 +81,14 @@ export const executeJsTool = defineBrowserTool({ }, renderResult(result, expanded, theme) { - const details = result.details as - | { valueLength?: number; full?: string; pretty?: string; fullOutputPath?: string } - | undefined; - if (!details) return new Text(theme.fg("error", "execute_js: no details"), 0, 0); + const raw = asRecord(result.details); + if (raw === undefined) return new Text(theme.fg("error", "execute_js: no details"), 0, 0); + const details = { + valueLength: asNumber(raw["valueLength"]), + full: asString(raw["full"]), + pretty: asString(raw["pretty"]), + fullOutputPath: asString(raw["fullOutputPath"]), + }; const len = details.valueLength ?? 0; const full = details.full ?? ""; @@ -141,9 +146,7 @@ const MAX_SOURCE_BYTES = 1_000_000; type ContentItem = { readonly type: "text"; readonly text: string }; const isContentItem = (v: unknown): v is ContentItem => - typeof v === "object" && v !== null - && (v as Readonly<Record<string, unknown>>)["type"] === "text" - && typeof (v as Readonly<Record<string, unknown>>)["text"] === "string"; + isRecord(v) && v["type"] === "text" && typeof v["text"] === "string"; export const runScriptTool = defineBrowserTool({ name: "browser_run_script", @@ -211,12 +214,12 @@ export const runScriptTool = defineBrowserTool({ requireFromHere, ac.signal, (u: unknown) => { - if (typeof u !== "object" || u === null) return; - const content = (u as Readonly<Record<string, unknown>>)["content"]; + if (!isRecord(u)) return; + const content = u["content"]; if (!Array.isArray(content) || content.length === 0) return; - const first = content[0]; - if (typeof first !== "object" || first === null) return; - const txt = (first as Readonly<Record<string, unknown>>)["text"]; + const first: unknown = content[0]; + if (!isRecord(first)) return; + const txt = first["text"]; if (typeof txt !== "string") return; try { onUpdate({ text: txt }); } catch { /* swallow */ } }, @@ -230,10 +233,10 @@ export const runScriptTool = defineBrowserTool({ clearTimeout(timeoutTimer); if (signal !== undefined) signal.removeEventListener("abort", onAbort); - if (typeof result !== "object" || result === null) { + if (!isRecord(result)) { return err({ kind: "invalid_state", message: `Script must return an object; got ${JSON.stringify(result)}` }); } - const content = (result as Readonly<Record<string, unknown>>)["content"]; + const content = result["content"]; if (!Array.isArray(content)) { return err({ kind: "invalid_state", message: "Script return value must have a content array" }); } @@ -241,11 +244,8 @@ export const runScriptTool = defineBrowserTool({ return err({ kind: "invalid_state", message: "Script content array must contain { type: 'text', text: string } items" }); } const textOut = content.map((c) => c.text).join("\n"); - const details = (result as Readonly<Record<string, unknown>>)["details"]; - const isPlainObject = typeof details === "object" && details !== null && !Array.isArray(details); - return isPlainObject - ? ok({ text: textOut, details: details as Readonly<Record<string, unknown>> }) - : ok({ text: textOut }); + const details = result["details"]; + return isRecord(details) ? ok({ text: textOut, details }) : ok({ text: textOut }); } catch (e) { clearTimeout(timeoutTimer); if (signal !== undefined) signal.removeEventListener("abort", onAbort); diff --git a/src/domains/network.ts b/src/domains/network.ts index dc23c6d..fa755b3 100644 --- a/src/domains/network.ts +++ b/src/domains/network.ts @@ -4,6 +4,7 @@ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; +import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards"; import type { NetworkRecord } from "../cdp/network-buffer"; const HttpGetArgs = Type.Object({ @@ -168,6 +169,57 @@ type NetworkDetails = { includeBodies: boolean; }; +const asNetworkRecord = (v: unknown): NetworkRecord | undefined => { + const o = asRecord(v); + if (o === undefined) return undefined; + const method = asString(o["method"]); + const url = asString(o["url"]); + const type = asString(o["type"]); + if (method === undefined || url === undefined || type === undefined) return undefined; + const status = asNumber(o["status"]); + const statusText = asString(o["statusText"]); + const mimeType = asString(o["mimeType"]); + const durationMs = asNumber(o["durationMs"]); + const responseBodySize = asNumber(o["responseBodySize"]); + const failed = asBoolean(o["failed"]); + const errorText = asString(o["errorText"]); + const bodyRaw = o["body"]; + const body = typeof bodyRaw === "string" || bodyRaw === null ? bodyRaw : undefined; + return { + requestId: asString(o["requestId"]) ?? "", + method, + url, + type, + requestStartedMs: asNumber(o["requestStartedMs"]) ?? 0, + requestBodySize: asNumber(o["requestBodySize"]) ?? 0, + ...(status !== undefined ? { status } : {}), + ...(statusText !== undefined ? { statusText } : {}), + ...(mimeType !== undefined ? { mimeType } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(responseBodySize !== undefined ? { responseBodySize } : {}), + ...(failed !== undefined ? { failed } : {}), + ...(errorText !== undefined ? { errorText } : {}), + ...(body !== undefined ? { body } : {}), + }; +}; + +const asNetworkDetails = (v: unknown): NetworkDetails | undefined => { + const o = asRecord(v); + if (o === undefined) return undefined; + const requests = asArrayOf(o["requests"], asNetworkRecord); + if (requests === undefined) return undefined; + const fullOutputPath = asString(o["fullOutputPath"]); + return { + total: asNumber(o["total"]) ?? requests.length, + returned: asNumber(o["returned"]) ?? requests.length, + bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false, + truncated: asBoolean(o["truncated"]) ?? false, + requests, + includeBodies: asBoolean(o["includeBodies"]) ?? false, + ...(fullOutputPath !== undefined ? { fullOutputPath } : {}), + }; +}; + export const networkRequestsTool = defineBrowserTool({ name: "browser_network_requests", label: "Browser Network Requests", @@ -235,7 +287,7 @@ export const networkRequestsTool = defineBrowserTool({ }, renderResult(result, expanded, theme) { - const details = result.details as NetworkDetails | undefined; + const details = asNetworkDetails(result.details); if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0); const md = renderNetworkMarkdown(details.requests, { diff --git a/src/domains/ref-resolve.ts b/src/domains/ref-resolve.ts index de83521..a80c620 100644 --- a/src/domains/ref-resolve.ts +++ b/src/domains/ref-resolve.ts @@ -2,7 +2,7 @@ import type { BrowserClient } from "../client"; import { type Result, err, ok } from "../util/result"; import type { ToolErr } from "../util/tool"; import { type Box, boxOf } from "./box"; -import { buildTree, collectInteractiveTargets, type RawAxNode, type SlimNode } from "./snapshot"; +import { buildTree, collectInteractiveTargets, type SlimNode } from "./snapshot"; const staleErr = (ref: string): ToolErr => ({ kind: "invalid_state", @@ -78,7 +78,7 @@ export const interactiveDiff = async (client: BrowserClient): Promise<string> => const axRes = await session.call("Accessibility.getFullAXTree", {}); if (!axRes.success) return ""; - const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes; + const rawNodes = axRes.data.nodes; const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 }); const targets = collectInteractiveTargets(slim); diff --git a/src/domains/render.ts b/src/domains/render.ts index f6a6535..1e4e30d 100644 --- a/src/domains/render.ts +++ b/src/domains/render.ts @@ -11,6 +11,7 @@ import { Markdown, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import type { Component } from "@mariozechner/pi-tui"; import type { Theme } from "@mariozechner/pi-coding-agent"; +import { isRecord } from "../util/guards"; const COMPACT_PREVIEW_LINES = 6; @@ -24,16 +25,11 @@ export type ExpandableText = { readonly fullOutputPath?: string; }; -const isExpandableText = (value: unknown): value is ExpandableText => { - if (typeof value !== "object" || value === null) return false; - const v = value as Record<string, unknown>; - return typeof v["summary"] === "string" && typeof v["body"] === "string"; -}; +const isExpandableText = (value: unknown): value is ExpandableText => + isRecord(value) && typeof value["summary"] === "string" && typeof value["body"] === "string"; -const extractRender = (details: unknown): unknown => { - if (typeof details !== "object" || details === null) return undefined; - return (details as Record<string, unknown>)["render"]; -}; +const extractRender = (details: unknown): unknown => + isRecord(details) ? details["render"] : undefined; /** * Build a renderResult over an ExpandableText stored in `details.render`. diff --git a/src/domains/screenshot.ts b/src/domains/screenshot.ts index c6cc87b..2e9cfdc 100644 --- a/src/domains/screenshot.ts +++ b/src/domains/screenshot.ts @@ -7,6 +7,7 @@ import { type Result, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { screenshotPath } from "../util/paths"; import { loadSharp } from "../util/sharp-shim"; +import { asRecord, asString } from "../util/guards"; import { safeJs } from "../util/js-template"; import { cdpCall } from "./cdp-call"; @@ -91,15 +92,16 @@ export const screenshotTool = defineBrowserTool({ }, renderResult(result, _expanded, theme) { - const details = result.details as { path?: string; format?: string } | undefined; - const filePath = details?.path; + const raw = asRecord(result.details); + const filePath = raw === undefined ? undefined : asString(raw["path"]); if (!filePath) { return new Text(theme.fg("error", "Screenshot path missing"), 0, 0); } + const format = raw === undefined ? undefined : asString(raw["format"]); try { const buf = readFileSync(filePath); const b64 = buf.toString("base64"); - const mimeType = details?.format === "jpeg" ? "image/jpeg" : "image/png"; + const mimeType = format === "jpeg" ? "image/jpeg" : "image/png"; const imageTheme: ImageTheme = { fallbackColor: (str: string) => theme.fg("dim", str), }; diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts index ec5c894..c015203 100644 --- a/src/domains/snapshot.ts +++ b/src/domains/snapshot.ts @@ -3,12 +3,14 @@ import { Type } from "typebox"; import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import type { CdpSession } from "../cdp/session"; +import type { AxNodeResult } from "../cdp/commands"; import { type Box, boxOf } from "./box"; import { cdpCall } from "./cdp-call"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; import { screenshotPath } from "../util/paths"; +import { asBoolean, asNumber, asRecord, asString } from "../util/guards"; const SnapshotArgs = Type.Object({ includeScreenshot: Type.Optional( @@ -28,20 +30,8 @@ const SnapshotArgs = Type.Object({ ), }); -// Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only. -type AxValue = { value?: unknown }; -export type RawAxNode = { - nodeId: string; - parentId?: string; - childIds?: ReadonlyArray<string>; - ignored?: boolean; - role?: AxValue; - name?: AxValue; - value?: AxValue; - description?: AxValue; - properties?: ReadonlyArray<{ name?: string; value?: AxValue }>; - backendDOMNodeId?: number; -}; +export type RawAxNode = AxNodeResult; +type AxValue = NonNullable<RawAxNode["role"]>; export type SlimNode = { role: string; @@ -322,7 +312,7 @@ export const snapshotTool = defineBrowserTool({ // 1. AX tree const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {}); if (!axRes.success) return axRes; - const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes; + const rawNodes = axRes.data.nodes; // 2. Page url/title const piRes = await client.pageInfo(); @@ -412,10 +402,17 @@ export const snapshotTool = defineBrowserTool({ }, renderResult(result, expanded, theme) { - const details = result.details as - | (SnapshotDetails & { summary?: string; ok?: boolean }) - | undefined; - if (!details) return new Text(theme.fg("error", "snapshot: no details"), 0, 0); + const raw = asRecord(result.details); + if (raw === undefined) return new Text(theme.fg("error", "snapshot: no details"), 0, 0); + const details = { + nodeCount: asNumber(raw["nodeCount"]), + truncated: asBoolean(raw["truncated"]), + fullOutputPath: asString(raw["fullOutputPath"]), + screenshotPath: asString(raw["screenshotPath"]), + url: asString(raw["url"]), + title: asString(raw["title"]), + summary: asString(raw["summary"]), + }; const titleLine = details.title ? ` "${details.title}"` : ""; const summary = details.summary ?? ""; diff --git a/src/index.ts b/src/index.ts index e421511..9029940 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,9 +33,10 @@ import { type BrowserState, defaultState, persistState, restoreState } from "./s import { registerAllTools } from "./registry"; import { cleanupTempDirs } from "./util/truncate"; import { createDaemonTransport } from "./cdp/daemon-transport"; +import { asString } from "./util/guards"; export default function browserHarnessExtension(pi: ExtensionAPI): void { - const flagNs = pi.getFlag("browser-namespace") as string | undefined; + const flagNs = asString(pi.getFlag("browser-namespace")); const namespace = flagNs ?? `pi-${Math.random().toString(36).slice(2, 10)}`; let state: BrowserState = defaultState(namespace); diff --git a/src/state.ts b/src/state.ts index d4b1534..601f7e0 100644 --- a/src/state.ts +++ b/src/state.ts @@ -6,6 +6,7 @@ */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { asArrayOf, asNumber, asString, isRecord } from "./util/guards"; export type BrowserState = { readonly namespace: string; @@ -31,13 +32,29 @@ export const persistState = (pi: ExtensionAPI, state: BrowserState): void => { * If `currentNamespace` is supplied (e.g. from the --browser-namespace flag), * it overrides whatever is in the persisted entry. */ +const asPersistedState = (v: unknown): Partial<BrowserState> | undefined => { + if (!isRecord(v)) return undefined; + const namespace = asString(v["namespace"]); + const remoteBrowserId = asString(v["remoteBrowserId"]); + const ownedTargetIds = asArrayOf(v["ownedTargetIds"], asString); + const harnessWindowTargetId = asString(v["harnessWindowTargetId"]); + const harnessWindowId = asNumber(v["harnessWindowId"]); + return { + ...(namespace !== undefined ? { namespace } : {}), + ...(remoteBrowserId !== undefined ? { remoteBrowserId } : {}), + ...(ownedTargetIds !== undefined ? { ownedTargetIds } : {}), + ...(harnessWindowTargetId !== undefined ? { harnessWindowTargetId } : {}), + ...(harnessWindowId !== undefined ? { harnessWindowId } : {}), + }; +}; + export const restoreState = (ctx: ExtensionContext, currentNamespace?: string): BrowserState => { const branchEntries = ctx.sessionManager.getBranch(); const fallback = defaultState(currentNamespace); for (let i = branchEntries.length - 1; i >= 0; i--) { const entry = branchEntries[i]; if (entry?.type === "custom" && entry.customType === "browser-harness-state") { - const data = entry.data as Partial<BrowserState> | undefined; + const data = asPersistedState(entry.data); if (data) { return { ...fallback, diff --git a/src/util/guards.ts b/src/util/guards.ts new file mode 100644 index 0000000..2744c68 --- /dev/null +++ b/src/util/guards.ts @@ -0,0 +1,30 @@ +export const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> => + typeof v === "object" && v !== null && !Array.isArray(v); + +export const asRecord = (v: unknown): Readonly<Record<string, unknown>> | undefined => + isRecord(v) ? v : undefined; + +export const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); + +export const asNumber = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined); + +export const asBoolean = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined); + +export const asArrayOf = <T>( + v: unknown, + item: (x: unknown) => T | undefined, +): ReadonlyArray<T> | undefined => { + if (!Array.isArray(v)) return undefined; + const out: T[] = []; + for (const x of v) { + const parsed = item(x); + if (parsed === undefined) return undefined; + out.push(parsed); + } + return out; +}; + +export const errnoCode = (e: unknown): string | undefined => { + if (!(e instanceof Error) || !("code" in e)) return undefined; + return asString(e.code); +}; diff --git a/src/util/sharp-shim.ts b/src/util/sharp-shim.ts index a9f145e..36dd536 100644 --- a/src/util/sharp-shim.ts +++ b/src/util/sharp-shim.ts @@ -20,21 +20,21 @@ export type SharpLoad = | { readonly kind: "missing" } | { readonly kind: "error"; readonly message: string }; +const SHARP_SPECIFIER: string = "sharp"; + +const MISSING_MODULE_MARKERS: ReadonlyArray<string> = [ + "Cannot find module", + "MODULE_NOT_FOUND", + "ERR_MODULE_NOT_FOUND", +]; + export const loadSharp = async (): Promise<SharpLoad> => { let mod: unknown; try { - // sharp is an optional dependency; it may not be installed. - // @ts-ignore — optional peer dependency not required at compile time - mod = await import("sharp"); + mod = await import(SHARP_SPECIFIER); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - if ( - msg.includes("Cannot find module") || - msg.includes("MODULE_NOT_FOUND") || - msg.includes("ERR_MODULE_NOT_FOUND") - ) { - return { kind: "missing" }; - } + if (MISSING_MODULE_MARKERS.some((marker) => msg.includes(marker))) return { kind: "missing" }; return { kind: "error", message: msg }; } if (mod === null || mod === undefined) return { kind: "missing" }; diff --git a/src/util/tool.ts b/src/util/tool.ts index 90a2e15..e4bb5ba 100644 --- a/src/util/tool.ts +++ b/src/util/tool.ts @@ -1,7 +1,6 @@ import type { TSchema, Static } from "typebox"; import type { AgentToolResult, - AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, Theme, @@ -75,10 +74,12 @@ export const defineBrowserTool = <S extends TSchema>( type OkDetails = { readonly ok: true } & Readonly<Record<string, unknown>>; type ErrDetails = { readonly ok: false; readonly kind: ToolErrKind; readonly message: string } & Readonly<Record<string, unknown>>; +type ErrorFlagged<D> = AgentToolResult<D> & { readonly isError?: boolean }; + const toToolResult = ( r: Result<ToolOk, ToolErr>, toolName: string, -): AgentToolResult<OkDetails | ErrDetails> => { +): ErrorFlagged<OkDetails | ErrDetails> => { if (r.success) { const details: OkDetails = { ok: true, ...(r.data.details ?? {}) }; return { @@ -93,10 +94,7 @@ const toToolResult = ( ...(r.error.details ?? {}), }; return { - // isError is an extension the pi-coding-agent runtime picks up from the result object; - // AgentToolResult<T> itself doesn't declare it but the runtime reads it via duck-typing. - // We cast through unknown to avoid widening the return type. - ...(({ isError: true }) as unknown as object), + isError: true, content: [{ type: "text", text: `${toolName} failed (${r.error.kind}): ${r.error.message}` }], details, }; @@ -111,6 +109,8 @@ export const registerBrowserTool = ( // by casting def to the generic form. The cast is safe because defineBrowserTool // ensures the handler, parameters, and renderCall were all created with the same S. type S = TSchema; + const defRenderCall = def.renderCall; + const defRenderResult = def.renderResult; const td: ToolDefinition<S> = { name: def.name, label: def.label, @@ -118,25 +118,22 @@ export const registerBrowserTool = ( promptSnippet: def.promptSnippet, promptGuidelines: [...def.promptGuidelines], parameters: def.parameters, - ...(def.renderCall + ...(defRenderCall ? { renderCall: (args: Static<S>, theme: Theme, _ctx: Parameters<NonNullable<ToolDefinition<S>["renderCall"]>>[2]) => - // Non-null assertion safe: this branch only runs when renderCall was provided. // Cast from never->Component to unknown->Component: safe because at runtime // args is always the concrete Static<S> value the tool was defined with. - (def.renderCall as (args: unknown, theme: Theme) => Component)(args, theme), + (defRenderCall as (args: unknown, theme: Theme) => Component)(args, theme), } : {}), - ...(def.renderResult + ...(defRenderResult ? { renderResult: ( result: AgentToolResult<unknown>, options: ToolRenderResultOptions, theme: Theme, _ctx: Parameters<NonNullable<ToolDefinition<S>["renderResult"]>>[3], - ) => - // Non-null assertion safe: this branch only runs when renderResult was provided. - def.renderResult!(result, options.expanded ?? false, theme), + ) => defRenderResult(result, options.expanded ?? false, theme), } : {}), async execute(_toolCallId, args, signal, onUpdate, extensionCtx) { @@ -184,13 +181,11 @@ export const registerBrowserTool = ( signal, onUpdate: (u) => { if (onUpdate) { - // AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails. const update: AgentToolResult<OkDetails> = { content: [{ type: "text", text: u.text }], details: { ok: true, ...(u.details ?? {}) }, }; - // Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails). - (onUpdate as AgentToolUpdateCallback<OkDetails>)(update); + onUpdate(update); } }, extensionCtx, |
