import { mkdir, 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";
import { cdpCall, cdpCallBrowser, evalJs } from "./cdp-call";
import { resolveRefToBackendId } from "./ref-resolve";
const UploadArgs = Type.Object({
ref: Type.Optional(
Type.String({ description: "Stable ref of the file from browser_snapshot. PREFERRED over selector." }),
),
selector: Type.Optional(Type.String({ description: "CSS selector of the file (fallback when no ref)" })),
filePath: Type.String({ description: "Absolute path to the file to upload" }),
});
const tryRefUpload = async (
client: BrowserClient,
backendId: number,
filePath: string,
): Promise> => {
const set = await cdpCall(client, "DOM.setFileInputFiles", { files: [filePath], backendNodeId: backendId });
if (!set.success) return set;
return ok(undefined);
};
const verifyReadable = async (filePath: string): Promise> => {
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> => {
const doc = await cdpCall(client, "DOM.getDocument", { depth: -1 });
if (!doc.success) return doc;
const q = await cdpCall(client, "DOM.querySelector", { nodeId: doc.data.root.nodeId, selector });
if (!q.success) return q;
const nodeId = q.data.nodeId;
if (!nodeId) return err({ kind: "invalid_state", message: `Selector matched 0 file inputs: ${selector}` });
const set = await cdpCall(client, "DOM.setFileInputFiles", { files: [filePath], nodeId });
if (!set.success) return set;
const verify = await evalJs(client, safeJs`document.querySelector(${selector})?.files?.length || 0`);
if (!verify.success) return verify;
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> => {
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 evalJs(client, expr);
if (!r.success) return r;
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 via CDP, with a JS-DataTransfer fallback for stubborn pages. PREFERRED: pass `ref` from browser_snapshot; fallback: a CSS `selector`.",
promptSnippet: "Upload a file to a file input by ref (preferred) or selector",
promptGuidelines: [
"PREFER `ref` from browser_snapshot over a CSS selector — survives re-renders. (File inputs are often hidden; snapshot still surfaces them.)",
"File path must be absolute and readable.",
],
parameters: UploadArgs,
concurrency: "serialized",
async handler(args, { client }): Promise> {
const readable = await verifyReadable(args.filePath);
if (!readable.success) return readable;
if (args.ref !== undefined) {
const backendId = resolveRefToBackendId(client, args.ref);
if (!backendId.success) return backendId;
const up = await tryRefUpload(client, backendId.data, args.filePath);
if (!up.success) return up;
return ok({ text: `Uploaded ${args.filePath} via CDP (ref ${args.ref})`, details: { mode: "cdp", ref: args.ref, filePath: args.filePath } });
}
if (args.selector === undefined) {
return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." });
}
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,
concurrency: "serialized",
async handler(args, { client }): Promise> {
try {
await mkdir(args.downloadPath, { recursive: true });
} catch (e) {
return err({
kind: "io_error",
message: `Cannot create download directory: ${args.downloadPath} (${e instanceof Error ? e.message : String(e)})`,
});
}
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 cdpCallBrowser(client, "Browser.setDownloadBehavior", {
behavior: "allow",
downloadPath: args.downloadPath,
eventsEnabled: true,
});
if (!r.success) return r;
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,
concurrency: "serialized",
async handler(args, { client }): Promise> {
const r = await cdpCall(client, "Page.printToPDF", {
printBackground: true,
preferCSSPageSize: true,
});
if (!r.success) return r;
const path = args.outputPath ?? pdfPath(client.namespace);
await writeFile(path, Buffer.from(r.data.data, "base64"));
return ok({ text: `PDF saved: ${path}`, details: { path } });
},
});