aboutsummaryrefslogtreecommitdiff
path: root/src/domains/files.ts
blob: 9a3a70532b0fbd9533d805e071af93cc20854070 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
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 <input> from browser_snapshot. PREFERRED over selector." }),
  ),
  selector: Type.Optional(Type.String({ description: "CSS selector of the file <input> (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<Result<void, ToolErr>> => {
  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<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 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<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 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 <input> 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: [
    "File inputs are often hidden; browser_snapshot still surfaces them.",
    "File path must be absolute and readable.",
  ],
  parameters: UploadArgs,
  concurrency: "serialized",
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
    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<Result<ToolOk, ToolErr>> {
    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<Result<ToolOk, ToolErr>> {
    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 } });
  },
});