aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 16:32:07 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 16:32:07 +0530
commit5664dc8f6f41e93a8c320d7084451e25f3d93438 (patch)
tree221289331b6b06ec41fe89bec7ebb0ce7e121b39
parente8650dc566bd0655348e1458772486eaf0ea90d6 (diff)
feat(domains): migrate upload_file (safeJs), download (validated dir), print_to_pdf
-rw-r--r--src/domains/files.ts186
-rw-r--r--src/registry.ts4
-rw-r--r--src/tools.ts167
3 files changed, 190 insertions, 167 deletions
diff --git a/src/domains/files.ts b/src/domains/files.ts
new file mode 100644
index 0000000..f0cb6b0
--- /dev/null
+++ b/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 } });
+ },
+});
diff --git a/src/registry.ts b/src/registry.ts
index 02a8c8e..9490cbf 100644
--- a/src/registry.ts
+++ b/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 @@ const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
currentTabTool,
switchTabTool,
newTabTool,
+ uploadFileTool,
+ downloadTool,
+ printToPdfTool,
];
export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => {
diff --git a/src/tools.ts b/src/tools.ts
index 581a7ca..44bf5c6 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -97,47 +97,6 @@ async function applyTruncation(
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 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void {
});
// ═══════════════════════════════════════════════════════════════════════════
- // 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({
@@ -481,58 +366,6 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void {
});
// ═══════════════════════════════════════════════════════════════════════════
- // 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,
- );
- },
- });
-
- // ═══════════════════════════════════════════════════════════════════════════
// browser_get_network_log
// ═══════════════════════════════════════════════════════════════════════════
pi.registerTool({