@poncho-ai/browser 0.6.26 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/browser@0.6.26 build /home/runner/work/poncho-ai/poncho-ai/packages/browser
2
+ > @poncho-ai/browser@0.7.0 build /home/runner/work/poncho-ai/poncho-ai/packages/browser
3
3
  > tsup src/index.ts --format esm --dts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -7,8 +7,8 @@
7
7
  CLI tsup v8.5.1
8
8
  CLI Target: es2022
9
9
  ESM Build start
10
- ESM dist/index.js 47.98 KB
11
- ESM ⚡️ Build success in 60ms
10
+ ESM dist/index.js 53.08 KB
11
+ ESM ⚡️ Build success in 63ms
12
12
  DTS Build start
13
- DTS ⚡️ Build success in 4894ms
14
- DTS dist/index.d.ts 13.77 KB
13
+ DTS ⚡️ Build success in 5156ms
14
+ DTS dist/index.d.ts 14.54 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @poncho-ai/browser
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#184](https://github.com/cesr/poncho-ai/pull/184) [`12ce2be`](https://github.com/cesr/poncho-ai/commit/12ce2be01c9d98b1d9aa634d4d8051c4c0094a44) Thanks [@cesr](https://github.com/cesr)! - Add `browser_download` so the agent can save files from the browser into the
8
+ VFS. The tool fetches a file using the page's logged-in session (so it works
9
+ for files behind a login) and writes the bytes straight to the tenant's VFS via
10
+ `ToolContext.vfs` — never through the model. `url` defaults to the current page,
11
+ or pass a same-origin link's href. The fetch runs inside the page (`evaluate`),
12
+ so it works identically for local and remote/cloud browsers (bytes return over
13
+ CDP). Capped at 25 MB. The harness browser system prompt now documents it under
14
+ a "Saving files" section.
15
+
3
16
  ## 0.6.26
4
17
 
5
18
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -144,6 +144,21 @@ declare class BrowserSession {
144
144
  url: string;
145
145
  title: string;
146
146
  }>;
147
+ /**
148
+ * Fetch a file using the page's own (logged-in) session and return its
149
+ * bytes, so the host can persist it (e.g. to a VFS). `url` defaults to the
150
+ * current page. The fetch runs INSIDE the page via `evaluate`, so it carries
151
+ * the site's cookies and works the same whether the browser is local or a
152
+ * remote/cloud provider (the bytes come back over CDP). Because it's a page
153
+ * `fetch`, same-origin and CORS-permissive URLs work; a cross-origin URL the
154
+ * site doesn't allow CORS for will fail — navigate to the file first (so it's
155
+ * same-origin) or pass its direct URL while on that site.
156
+ */
157
+ download(conversationId: string, url?: string): Promise<{
158
+ data: Buffer;
159
+ contentType: string;
160
+ filename: string;
161
+ }>;
147
162
  scroll(conversationId: string, direction: "up" | "down", amount?: number): Promise<void>;
148
163
  clickText(conversationId: string, text: string, exact?: boolean): Promise<void>;
149
164
  executeJs(conversationId: string, script: string): Promise<unknown>;
package/dist/index.js CHANGED
@@ -244,6 +244,28 @@ var SAME_TAB_INIT_SCRIPT = `
244
244
  } catch {}
245
245
  })();
246
246
  `;
247
+ function sanitizeName(name) {
248
+ const cleaned = name.trim().replace(/[/\\]/g, "_").replace(/\0/g, "");
249
+ return cleaned || "download";
250
+ }
251
+ function filenameFromDownload(disposition, url) {
252
+ const star = /filename\*=(?:UTF-8'')?["']?([^"';]+)/i.exec(disposition);
253
+ if (star?.[1]) {
254
+ try {
255
+ return sanitizeName(decodeURIComponent(star[1]));
256
+ } catch {
257
+ return sanitizeName(star[1]);
258
+ }
259
+ }
260
+ const plain = /filename=["']?([^"';]+)/i.exec(disposition);
261
+ if (plain?.[1]) return sanitizeName(plain[1]);
262
+ try {
263
+ const base = new URL(url).pathname.split("/").filter(Boolean).pop();
264
+ if (base) return sanitizeName(decodeURIComponent(base));
265
+ } catch {
266
+ }
267
+ return "download";
268
+ }
247
269
  var BrowserSession = class {
248
270
  config;
249
271
  sessionId;
@@ -693,6 +715,55 @@ var BrowserSession = class {
693
715
  this.unlock();
694
716
  }
695
717
  }
718
+ /**
719
+ * Fetch a file using the page's own (logged-in) session and return its
720
+ * bytes, so the host can persist it (e.g. to a VFS). `url` defaults to the
721
+ * current page. The fetch runs INSIDE the page via `evaluate`, so it carries
722
+ * the site's cookies and works the same whether the browser is local or a
723
+ * remote/cloud provider (the bytes come back over CDP). Because it's a page
724
+ * `fetch`, same-origin and CORS-permissive URLs work; a cross-origin URL the
725
+ * site doesn't allow CORS for will fail — navigate to the file first (so it's
726
+ * same-origin) or pass its direct URL while on that site.
727
+ */
728
+ async download(conversationId, url) {
729
+ await this.lock();
730
+ try {
731
+ const mgr = await this.ensureManager();
732
+ await this.switchToConversation(mgr, conversationId);
733
+ const page = mgr.getPage();
734
+ const target = url && url.trim() ? url.trim() : page.url();
735
+ if (!target || target === "about:blank") {
736
+ throw new Error("no URL to download (open the file's page first, or pass a url)");
737
+ }
738
+ const MAX_BYTES = 25 * 1024 * 1024;
739
+ const expr = `(async () => {
740
+ const res = await fetch(${JSON.stringify(target)}, { credentials: "include" });
741
+ if (!res.ok) throw new Error("HTTP " + res.status + " " + res.statusText);
742
+ const buf = new Uint8Array(await res.arrayBuffer());
743
+ if (buf.length > ${MAX_BYTES}) throw new Error("file too large: " + buf.length + " bytes (max ${MAX_BYTES})");
744
+ let bin = "";
745
+ const CH = 0x8000;
746
+ for (let i = 0; i < buf.length; i += CH) {
747
+ bin += String.fromCharCode.apply(null, buf.subarray(i, i + CH));
748
+ }
749
+ return {
750
+ base64: btoa(bin),
751
+ contentType: res.headers.get("content-type") || "",
752
+ disposition: res.headers.get("content-disposition") || "",
753
+ finalUrl: res.url || ${JSON.stringify(target)},
754
+ };
755
+ })()`;
756
+ const r = await page.evaluate(expr);
757
+ const data = Buffer.from(r.base64, "base64");
758
+ return {
759
+ data,
760
+ contentType: r.contentType,
761
+ filename: filenameFromDownload(r.disposition, r.finalUrl)
762
+ };
763
+ } finally {
764
+ this.unlock();
765
+ }
766
+ }
696
767
  async scroll(conversationId, direction, amount) {
697
768
  await this.lock();
698
769
  try {
@@ -1261,6 +1332,47 @@ function createBrowserTools(getSession) {
1261
1332
  return { url: result.url, title: result.title, text: result.text };
1262
1333
  }
1263
1334
  },
1335
+ {
1336
+ name: "browser_download",
1337
+ description: "Download a file from the browser and save it into the user's virtual filesystem (VFS). Fetches the file using the browser's logged-in session, so it works for files behind a login \u2014 use it to keep a PDF, CSV, image, or other file the page offers. It fetches `url` (or the current page if you omit it), so for a download link on the page, grab its href from a snapshot first; for a file that opens in the browser, navigate to it and call this with no url. The fetch runs in the page, so the url should be same-origin with the current page (navigate to the file's site first if needed). Returns the saved VFS path and byte size \u2014 the bytes go straight to the VFS, not through the chat.",
1338
+ inputSchema: {
1339
+ type: "object",
1340
+ properties: {
1341
+ path: {
1342
+ type: "string",
1343
+ description: "Destination in the VFS. Include a filename (e.g. /downloads/report.pdf); parent folders are created as needed. End with '/' (e.g. /downloads/) to keep the file's own name."
1344
+ },
1345
+ url: {
1346
+ type: "string",
1347
+ description: "URL of the file to download. Optional \u2014 defaults to the current page's URL."
1348
+ }
1349
+ },
1350
+ required: ["path"]
1351
+ },
1352
+ handler: async (input, context) => {
1353
+ const session = getSession();
1354
+ const vfs = context.vfs;
1355
+ if (!vfs) throw new Error("VFS is not available in this environment");
1356
+ const dest0 = String(input.path ?? "").trim();
1357
+ if (!dest0) throw new Error("path is required");
1358
+ const url = input.url != null ? String(input.url) : void 0;
1359
+ const { data, contentType, filename } = await session.download(
1360
+ context.conversationId ?? "__default__",
1361
+ url
1362
+ );
1363
+ let dest = dest0.startsWith("/") ? dest0 : `/${dest0}`;
1364
+ if (dest.endsWith("/")) dest = `${dest}${filename}`;
1365
+ const slash = dest.lastIndexOf("/");
1366
+ if (slash > 0) {
1367
+ try {
1368
+ await vfs.mkdir(dest.slice(0, slash), { recursive: true });
1369
+ } catch {
1370
+ }
1371
+ }
1372
+ await vfs.writeFile(dest, new Uint8Array(data), contentType || void 0);
1373
+ return { path: dest, bytes: data.length, ...contentType ? { contentType } : {} };
1374
+ }
1375
+ },
1264
1376
  {
1265
1377
  name: "browser_screenshot",
1266
1378
  description: "Take a screenshot of the current page. Returns the image so you can see exactly what the page looks like. Use this when you need to see visual layout, verify actions, or read content that isn't in the accessibility tree.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/browser",
3
- "version": "0.6.26",
3
+ "version": "0.7.0",
4
4
  "description": "Browser automation for Poncho agents, powered by agent-browser",
5
5
  "repository": {
6
6
  "type": "git",
package/src/session.ts CHANGED
@@ -146,6 +146,29 @@ interface ConversationTab {
146
146
  lastUsed: number;
147
147
  }
148
148
 
149
+ /** Strip path separators / nulls so a derived name can't escape its folder. */
150
+ function sanitizeName(name: string): string {
151
+ const cleaned = name.trim().replace(/[/\\]/g, "_").replace(/\0/g, "");
152
+ return cleaned || "download";
153
+ }
154
+
155
+ /** Derive a filename from a Content-Disposition header, falling back to the
156
+ * URL's last path segment, then a generic "download". */
157
+ function filenameFromDownload(disposition: string, url: string): string {
158
+ const star = /filename\*=(?:UTF-8'')?["']?([^"';]+)/i.exec(disposition);
159
+ if (star?.[1]) {
160
+ try { return sanitizeName(decodeURIComponent(star[1])); }
161
+ catch { return sanitizeName(star[1]); }
162
+ }
163
+ const plain = /filename=["']?([^"';]+)/i.exec(disposition);
164
+ if (plain?.[1]) return sanitizeName(plain[1]);
165
+ try {
166
+ const base = new URL(url).pathname.split("/").filter(Boolean).pop();
167
+ if (base) return sanitizeName(decodeURIComponent(base));
168
+ } catch { /* not a parseable URL */ }
169
+ return "download";
170
+ }
171
+
149
172
  export class BrowserSession {
150
173
  private readonly config: BrowserConfig;
151
174
  private readonly sessionId: string;
@@ -655,6 +678,66 @@ export class BrowserSession {
655
678
  }
656
679
  }
657
680
 
681
+ /**
682
+ * Fetch a file using the page's own (logged-in) session and return its
683
+ * bytes, so the host can persist it (e.g. to a VFS). `url` defaults to the
684
+ * current page. The fetch runs INSIDE the page via `evaluate`, so it carries
685
+ * the site's cookies and works the same whether the browser is local or a
686
+ * remote/cloud provider (the bytes come back over CDP). Because it's a page
687
+ * `fetch`, same-origin and CORS-permissive URLs work; a cross-origin URL the
688
+ * site doesn't allow CORS for will fail — navigate to the file first (so it's
689
+ * same-origin) or pass its direct URL while on that site.
690
+ */
691
+ async download(
692
+ conversationId: string,
693
+ url?: string,
694
+ ): Promise<{ data: Buffer; contentType: string; filename: string }> {
695
+ await this.lock();
696
+ try {
697
+ const mgr = await this.ensureManager();
698
+ await this.switchToConversation(mgr, conversationId);
699
+ const page = mgr.getPage();
700
+ const target = url && url.trim() ? url.trim() : page.url();
701
+ if (!target || target === "about:blank") {
702
+ throw new Error("no URL to download (open the file's page first, or pass a url)");
703
+ }
704
+ const MAX_BYTES = 25 * 1024 * 1024;
705
+ // Build the in-page fetch. JSON.stringify safely escapes the URL into the
706
+ // evaluated source. Base64 in-page so the bytes survive the JSON channel.
707
+ const expr = `(async () => {
708
+ const res = await fetch(${JSON.stringify(target)}, { credentials: "include" });
709
+ if (!res.ok) throw new Error("HTTP " + res.status + " " + res.statusText);
710
+ const buf = new Uint8Array(await res.arrayBuffer());
711
+ if (buf.length > ${MAX_BYTES}) throw new Error("file too large: " + buf.length + " bytes (max ${MAX_BYTES})");
712
+ let bin = "";
713
+ const CH = 0x8000;
714
+ for (let i = 0; i < buf.length; i += CH) {
715
+ bin += String.fromCharCode.apply(null, buf.subarray(i, i + CH));
716
+ }
717
+ return {
718
+ base64: btoa(bin),
719
+ contentType: res.headers.get("content-type") || "",
720
+ disposition: res.headers.get("content-disposition") || "",
721
+ finalUrl: res.url || ${JSON.stringify(target)},
722
+ };
723
+ })()`;
724
+ const r = (await page.evaluate(expr)) as {
725
+ base64: string;
726
+ contentType: string;
727
+ disposition: string;
728
+ finalUrl: string;
729
+ };
730
+ const data = Buffer.from(r.base64, "base64");
731
+ return {
732
+ data,
733
+ contentType: r.contentType,
734
+ filename: filenameFromDownload(r.disposition, r.finalUrl),
735
+ };
736
+ } finally {
737
+ this.unlock();
738
+ }
739
+ }
740
+
658
741
  async scroll(conversationId: string, direction: "up" | "down", amount?: number): Promise<void> {
659
742
  await this.lock();
660
743
  try {
package/src/tools.ts CHANGED
@@ -176,6 +176,55 @@ export function createBrowserTools(
176
176
  return { url: result.url, title: result.title, text: result.text };
177
177
  },
178
178
  },
179
+ {
180
+ name: "browser_download",
181
+ description:
182
+ "Download a file from the browser and save it into the user's virtual filesystem (VFS). " +
183
+ "Fetches the file using the browser's logged-in session, so it works for files behind a login — " +
184
+ "use it to keep a PDF, CSV, image, or other file the page offers. " +
185
+ "It fetches `url` (or the current page if you omit it), so for a download link on the page, grab its href from a snapshot first; " +
186
+ "for a file that opens in the browser, navigate to it and call this with no url. " +
187
+ "The fetch runs in the page, so the url should be same-origin with the current page (navigate to the file's site first if needed). " +
188
+ "Returns the saved VFS path and byte size — the bytes go straight to the VFS, not through the chat.",
189
+ inputSchema: {
190
+ type: "object",
191
+ properties: {
192
+ path: {
193
+ type: "string",
194
+ description:
195
+ "Destination in the VFS. Include a filename (e.g. /downloads/report.pdf); parent folders are created as needed. " +
196
+ "End with '/' (e.g. /downloads/) to keep the file's own name.",
197
+ },
198
+ url: {
199
+ type: "string",
200
+ description:
201
+ "URL of the file to download. Optional — defaults to the current page's URL.",
202
+ },
203
+ },
204
+ required: ["path"],
205
+ },
206
+ handler: async (input: BrowserToolInput, context: ToolContext) => {
207
+ const session = getSession();
208
+ const vfs = context.vfs;
209
+ if (!vfs) throw new Error("VFS is not available in this environment");
210
+ const dest0 = String(input.path ?? "").trim();
211
+ if (!dest0) throw new Error("path is required");
212
+ const url = input.url != null ? String(input.url) : undefined;
213
+ const { data, contentType, filename } = await session.download(
214
+ context.conversationId ?? "__default__",
215
+ url,
216
+ );
217
+ // A trailing slash (or bare folder) means "use the file's own name".
218
+ let dest = dest0.startsWith("/") ? dest0 : `/${dest0}`;
219
+ if (dest.endsWith("/")) dest = `${dest}${filename}`;
220
+ const slash = dest.lastIndexOf("/");
221
+ if (slash > 0) {
222
+ try { await vfs.mkdir(dest.slice(0, slash), { recursive: true }); } catch { /* exists */ }
223
+ }
224
+ await vfs.writeFile(dest, new Uint8Array(data), contentType || undefined);
225
+ return { path: dest, bytes: data.length, ...(contentType ? { contentType } : {}) };
226
+ },
227
+ },
179
228
  {
180
229
  name: "browser_screenshot",
181
230
  description: