all repos — sz-chrome-remote-cdp @ 5664dc8f6f41e93a8c320d7084451e25f3d93438

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

feat(domains): migrate upload_file (safeJs), download (validated dir), print_to_pdf
amankumarsingh77 amankumarsingh7702@gmal.com
Sat, 02 May 2026 16:32:07 +0530
commit

5664dc8f6f41e93a8c320d7084451e25f3d93438

parent

e8650dc566bd0655348e1458772486eaf0ea90d6

3 files changed, 190 insertions(+), 167 deletions(-)

jump to
A src/domains/files.ts

@@ -0,0 +1,186 @@

+import { access, constants, stat, readFile, writeFile } from "node:fs/promises"; +import { basename } from "node:path"; +import { Type } from "typebox"; +import type { BrowserClient } from "../client"; +import { type Result, err, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; +import { safeJs } from "../util/js-template"; +import { pdfPath } from "../util/paths"; + +const UploadArgs = Type.Object({ + selector: Type.String({ description: "CSS selector of the file <input>" }), + filePath: Type.String({ description: "Absolute path to the file to upload" }), +}); + +const verifyReadable = async (filePath: string): Promise<Result<void, ToolErr>> => { + try { + await access(filePath, constants.R_OK); + return ok(undefined); + } catch (e) { + return err({ + kind: "io_error", + message: `Cannot read file: ${filePath} (${e instanceof Error ? e.message : String(e)})`, + }); + } +}; + +const tryCdpUpload = async ( + client: BrowserClient, + selector: string, + filePath: string, +): Promise<Result<void, ToolErr>> => { + const doc = await client.session().call("DOM.getDocument", { depth: -1 }); + if (!doc.success) return err({ kind: "cdp_error", message: doc.error.message }); + // CDP boundary cast: DOM.getDocument returns { root: { nodeId: number } } + const root = (doc.data as { root: { nodeId: number } }).root; + const q = await client.session().call("DOM.querySelector", { nodeId: root.nodeId, selector }); + if (!q.success) return err({ kind: "cdp_error", message: q.error.message }); + // CDP boundary cast: DOM.querySelector returns { nodeId: number } + const nodeId = (q.data as { nodeId: number }).nodeId; + if (!nodeId) return err({ kind: "invalid_state", message: `Selector matched 0 file inputs: ${selector}` }); + const set = await client.session().call("DOM.setFileInputFiles", { files: [filePath], nodeId }); + if (!set.success) return err({ kind: "cdp_error", message: set.error.message }); + const verify = await client.evaluateJs(safeJs`document.querySelector(${selector})?.files?.length || 0`); + if (!verify.success) return err({ kind: "cdp_error", message: verify.error.message }); + if (Number(verify.data ?? 0) === 0) { + return err({ kind: "invalid_state", message: "CDP upload reported success but file count is 0" }); + } + return ok(undefined); +}; + +const inferMime = (name: string): string => { + if (name.endsWith(".png")) return "image/png"; + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg"; + if (name.endsWith(".pdf")) return "application/pdf"; + if (name.endsWith(".json")) return "application/json"; + return "text/plain"; +}; + +const jsFallbackUpload = async ( + client: BrowserClient, + selector: string, + filePath: string, +): Promise<Result<void, ToolErr>> => { + const buf = await readFile(filePath); + const st = await stat(filePath); + const name = basename(filePath); + const mime = inferMime(name); + const expr = safeJs` + (() => { + const input = document.querySelector(${selector}); + if (!input || input.type !== 'file') throw new Error('File input not found'); + const bin = atob(${buf.toString("base64")}); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const file = new File([bytes], ${name}, { type: ${mime}, lastModified: ${st.mtimeMs} }); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + return input.files.length; + })() + `; + const r = await client.evaluateJs(expr); + if (!r.success) return err({ kind: "cdp_error", message: r.error.message }); + if (!Number(r.data ?? 0)) return err({ kind: "invalid_state", message: "JS fallback set 0 files" }); + return ok(undefined); +}; + +export const uploadFileTool = defineBrowserTool({ + name: "browser_upload_file", + label: "Browser Upload File", + description: "Set files on a file <input> via CDP, with a JS-DataTransfer fallback for stubborn pages.", + promptSnippet: "Upload a file to a file input", + promptGuidelines: [ + "File path must be absolute and readable.", + "Selector must match a file input.", + ], + parameters: UploadArgs, + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + const readable = await verifyReadable(args.filePath); + if (!readable.success) return readable; + const cdp = await tryCdpUpload(client, args.selector, args.filePath); + if (cdp.success) { + return ok({ + text: `Uploaded ${args.filePath} via CDP`, + details: { mode: "cdp", filePath: args.filePath }, + }); + } + const js = await jsFallbackUpload(client, args.selector, args.filePath); + if (js.success) { + return ok({ + text: `Uploaded ${args.filePath} via JS DataTransfer`, + details: { mode: "js", filePath: args.filePath }, + }); + } + return err({ + kind: "cdp_error", + message: `Both CDP and JS fallback failed. CDP: ${cdp.error.message}; JS: ${js.error.message}`, + }); + }, +}); + +const DownloadArgs = Type.Object({ + downloadPath: Type.String({ description: "Absolute path to a writable directory where downloads should be saved" }), +}); + +export const downloadTool = defineBrowserTool({ + name: "browser_download", + label: "Browser Download", + description: "Configure Chrome's download behavior: set the save directory and disable the save-as prompt.", + promptSnippet: "Configure download directory", + promptGuidelines: [ + "Pass an absolute path to an existing writable directory.", + "Affects all subsequent downloads on this browser.", + ], + parameters: DownloadArgs, + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + try { + const s = await stat(args.downloadPath); + if (!s.isDirectory()) { + return err({ kind: "io_error", message: `Not a directory: ${args.downloadPath}` }); + } + await access(args.downloadPath, constants.W_OK); + } catch (e) { + return err({ + kind: "io_error", + message: `Download path unusable: ${e instanceof Error ? e.message : String(e)}`, + }); + } + const r = await client.session().callBrowser("Browser.setDownloadBehavior", { + behavior: "allow", + downloadPath: args.downloadPath, + eventsEnabled: true, + }); + if (!r.success) return err({ kind: "cdp_error", message: r.error.message }); + return ok({ + text: `Downloads will save to: ${args.downloadPath}`, + details: { downloadPath: args.downloadPath }, + }); + }, +}); + +const PrintPdfArgs = Type.Object({ + outputPath: Type.Optional(Type.String({ description: "Where to save the PDF. Default: tmpdir + uuid." })), +}); + +export const printToPdfTool = defineBrowserTool({ + name: "browser_print_to_pdf", + label: "Browser Print to PDF", + description: "Print the current page to a PDF file using Chrome's Page.printToPDF.", + promptSnippet: "Print the current page to PDF", + promptGuidelines: ["Default output path is in tmpdir; pass outputPath to control."], + parameters: PrintPdfArgs, + async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + const r = await client.session().call("Page.printToPDF", { + printBackground: true, + preferCSSPageSize: true, + }); + if (!r.success) return err({ kind: "cdp_error", message: r.error.message }); + // CDP boundary cast: Page.printToPDF returns { data: string } (base64-encoded PDF) + const data = (r.data as { data: string }).data; + const path = args.outputPath ?? pdfPath(client.namespace); + await writeFile(path, Buffer.from(data, "base64")); + return ok({ text: `PDF saved: ${path}`, details: { path } }); + }, +});
M src/registry.ts → src/registry.ts

@@ -10,6 +10,7 @@ import { screenshotTool } from "./domains/screenshot";

import { navigateTool, openUrlsTool } from "./domains/navigate"; import { goBackTool, goForwardTool, reloadTool } from "./domains/history"; import { listTabsTool, currentTabTool, switchTabTool, newTabTool } from "./domains/tabs"; +import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files"; const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [ clickTool,

@@ -31,6 +32,9 @@ listTabsTool,

currentTabTool, switchTabTool, newTabTool, + uploadFileTool, + downloadTool, + printToPdfTool, ]; export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => {
M src/tools.ts → src/tools.ts

@@ -97,47 +97,6 @@ // ── Registration ─────────────────────────────────────────────────────────────

export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { // ═══════════════════════════════════════════════════════════════════════════ - // browser_upload_file - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "browser_upload_file", - label: "Browser Upload File", - description: - "Set files on a file input element via CDP DOM.setFileInputFiles. " + - "Use this to upload files to forms without clicking the file picker dialog. " + - "The path must be an absolute file path accessible from the machine running Chrome.", - promptSnippet: "Upload a file to a file input element (bypasses file picker)", - promptGuidelines: [ - "Use browser_upload_file to set files on <input type='file'> elements — much faster than clicking the file picker.", - "The selector must match a file input element (<input type='file'>).", - "The filePath must be an absolute path on the machine where Chrome is running.", - "To create a temp file first, use write or bash to create the file, then upload it.", - ], - parameters: Type.Object({ - selector: Type.String({ description: "CSS selector for the file input element" }), - filePath: Type.String({ description: "Absolute path to the file to upload" }), - }), - async execute(_id, params) { - try { - await daemon.ensureAlive(); - await daemon.uploadFile(params.selector, params.filePath); - return { - content: [{ type: "text" as const, text: `Set file "${params.filePath}" on "${params.selector}".` }], - details: undefined, - }; - } catch (err) { - return { - isError: true, - content: [{ type: "text" as const, text: `Upload failed: ${err instanceof Error ? err.message : String(err)}` }], - details: undefined, - }; - } - }, - }); - - // ═══════════════════════════════════════════════════════════════════════════ - - // ═══════════════════════════════════════════════════════════════════════════ // browser_execute_js // ═══════════════════════════════════════════════════════════════════════════ pi.registerTool({

@@ -311,80 +270,6 @@ },

}); // ═══════════════════════════════════════════════════════════════════════════ - // browser_download - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "browser_download", - label: "Browser Download", - description: - "Configure the download directory and disable the save-as prompt for this tab. " + - "Call before triggering downloads to control where files are saved. " + - "After the download is triggered, call again with eventsOnly: true to check for download progress events.", - promptSnippet: "Configure browser download behavior (directory, no prompts)", - promptGuidelines: [ - "Use browser_download BEFORE clicking a download link or triggering a file save.", - "Set downloadPath to an absolute directory path (e.g. /tmp/downloads).", - "This disables the browser's save-as prompt, so files save automatically.", - "To check if a download started, call again with eventsOnly: true and look for Browser.downloadProgress events.", - ], - parameters: Type.Object({ - downloadPath: Type.Optional(Type.String({ description: "Absolute path to the download directory. Default: /tmp/browser-downloads" })), - eventsOnly: Type.Optional(Type.Boolean({ description: "If true, only check for recent download progress events. Default: false" })), - }), - async execute(_id, params) { - try { - await daemon.ensureAlive(); - - if (params.eventsOnly) { - // Drain events and filter for download progress - const events = await daemon.drainEvents(); - const downloads = events.filter( - (e) => - e.method === "Browser.downloadProgress" || - e.method === "Browser.downloadWillBegin", - ); - if (downloads.length === 0) { - return { - content: [{ type: "text" as const, text: "No recent download events found." }], - details: undefined, - }; - } - const lines = downloads.map((e) => { - const p = e.params as Record<string, unknown>; - return [ - `Event: ${e.method}`, - ` URL: ${p.url || "?"}`, - ` State: ${p.state || "?"}`, - p.receivedBytes !== undefined - ? ` Received: ${p.receivedBytes} / ${p.totalBytes || "?"}` - : "", - ] - .filter(Boolean) - .join("\n"); - }); - return { - content: [{ type: "text" as const, text: lines.join("\n\n") }], - details: undefined, - }; - } - - const dlPath = params.downloadPath || "/tmp/browser-downloads"; - await daemon.setDownloadBehavior(dlPath); - return { - content: [{ type: "text" as const, text: `Downloads will save to: ${dlPath} (save-as prompt disabled).` }], - details: undefined, - }; - } catch (err) { - return { - isError: true, - content: [{ type: "text" as const, text: `Download config failed: ${err instanceof Error ? err.message : String(err)}` }], - details: undefined, - }; - } - }, - }); - - // ═══════════════════════════════════════════════════════════════════════════ // browser_viewport_resize // ═══════════════════════════════════════════════════════════════════════════ pi.registerTool({

@@ -477,58 +362,6 @@ content: [{ type: "text" as const, text: `Drag-and-drop failed: ${err instanceof Error ? err.message : String(err)}` }],

details: undefined, }; } - }, - }); - - // ═══════════════════════════════════════════════════════════════════════════ - // browser_print_to_pdf - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "browser_print_to_pdf", - label: "Browser Print to PDF", - description: - "Print the current page to a PDF file via Page.printToPDF. " + - "Saves the PDF to the specified path. Includes background colors and respects CSS page size.", - promptSnippet: "Print the current page to a PDF file", - promptGuidelines: [ - "Use browser_print_to_pdf to save a page as PDF for archiving, sharing, or offline reading.", - "The output path should be an absolute file path (e.g. /tmp/page.pdf).", - "The page must be fully loaded before printing — use browser_wait_for_load first.", - ], - parameters: Type.Object({ - outputPath: Type.Optional(Type.String({ description: "Absolute path for the PDF file. Default: /tmp/browser-print-<timestamp>.pdf" })), - }), - async execute(_id, params) { - try { - await daemon.ensureAlive(); - const outputPath = - params.outputPath || - join(tmpdir(), `browser-print-${Date.now()}.pdf`); - await daemon.printToPDF(outputPath); - return { - content: [{ type: "text" as const, text: `PDF saved: ${outputPath}` }], - details: { path: outputPath }, - }; - } catch (err) { - return { - isError: true, - content: [{ type: "text" as const, text: `Print to PDF failed: ${err instanceof Error ? err.message : String(err)}` }], - details: undefined, - }; - } - }, - renderCall(_, theme) { - return new Text(`📄 Printing to PDF...`, 0, 0); - }, - renderResult(result, _options, theme) { - const path = (result.details as { path?: string })?.path; - return new Text( - path - ? theme.fg("accent", `📄 PDF: ${path}`) - : `📄 PDF saved`, - 0, - 0, - ); }, });