Skip to content

Commit 7a49b16

Browse files
fix(browser): downloads complete over CDP connections (#89416)
* fix(browser): surface navigate downloads in CDP mode * Validate navigation downloads before saving * fix(browser): observe navigation download capture timeouts * refactor(browser): unify managed download capture * test(browser): satisfy download fixture lint --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 797bca2 commit 7a49b16

13 files changed

Lines changed: 708 additions & 193 deletions

docs/tools/browser.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ OpenClaw can run a **dedicated Chrome/Brave/Edge/Chromium profile** that the age
1818
- A separate browser profile named **openclaw** (orange accent by default).
1919
- Deterministic tab control (list/open/focus/close).
2020
- Agent actions (click/type/drag/select), snapshots, screenshots, PDFs.
21+
- Playwright-backed profiles save direct attachment navigations under the managed downloads directory and return `{ url, suggestedFilename, path }` metadata after final-URL policy validation.
2122
- A bundled `browser-automation` skill that teaches agents the snapshot,
2223
stable-tab, stale-ref, and manual-blocker recovery loop when the browser
2324
plugin is enabled.

extensions/browser/src/browser/client-actions-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Shared result types for browser client action helpers.
33
*/
4+
import type { BrowserDownloadResult } from "./download-types.js";
45
import type { AnnotationItem } from "./screenshot-annotate.js";
56

67
/** Generic success result for action endpoints. */
@@ -11,6 +12,7 @@ export type BrowserActionTabResult = {
1112
ok: true;
1213
targetId: string;
1314
url?: string;
15+
download?: BrowserDownloadResult;
1416
};
1517

1618
/** Success result carrying a filesystem output path. */

extensions/browser/src/browser/client.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@ describe("browser client", () => {
193193
ok: true,
194194
targetId: "t1",
195195
url: "https://y",
196+
download: {
197+
url: "https://y/report.csv",
198+
suggestedFilename: "report.csv",
199+
path: "/tmp/openclaw/downloads/report.csv",
200+
},
196201
}),
197202
} as unknown as Response;
198203
}
@@ -331,6 +336,11 @@ describe("browser client", () => {
331336
});
332337
expect(navigation.ok).toBe(true);
333338
expect(navigation.targetId).toBe("t1");
339+
expect(navigation.download).toEqual({
340+
url: "https://y/report.csv",
341+
suggestedFilename: "report.csv",
342+
path: "/tmp/openclaw/downloads/report.csv",
343+
});
334344

335345
const act = await browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" });
336346
expect(act.ok).toBe(true);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** Metadata for a browser download saved under the configured output root. */
2+
export type BrowserDownloadResult = {
3+
url: string;
4+
suggestedFilename: string;
5+
path: string;
6+
};
7+
8+
/** Download metadata available before any bytes are written. */
9+
export type BrowserDownloadCandidate = Omit<BrowserDownloadResult, "path">;
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/** Shared Playwright download capture and output handling. */
2+
import crypto from "node:crypto";
3+
import path from "node:path";
4+
import type { Page } from "playwright-core";
5+
import type { BrowserDownloadCandidate, BrowserDownloadResult } from "./download-types.js";
6+
import { writeExternalFileWithinOutputRoot } from "./output-files.js";
7+
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
8+
import { sanitizeUntrustedFileName } from "./safe-filename.js";
9+
10+
export type BrowserDownloadCaptureState = {
11+
downloadWaiterDepth: number;
12+
};
13+
14+
export type BrowserDownloadCaptureOptions = {
15+
beforeSave?: (download: BrowserDownloadCandidate) => Promise<void> | void;
16+
mode?: "passive" | "explicit";
17+
outputPath?: string;
18+
outputRoot?: string;
19+
timeoutMessage?: string;
20+
};
21+
22+
export type PlaywrightDownload = {
23+
url?: () => string;
24+
suggestedFilename?: () => string;
25+
saveAs?: (outPath: string) => Promise<void>;
26+
};
27+
28+
function buildManagedDownloadPath(rootDir: string, fileName: string): string {
29+
const id = crypto.randomUUID();
30+
const safeName = sanitizeUntrustedFileName(fileName, "download.bin");
31+
return path.join(rootDir, `${id}-${safeName}`);
32+
}
33+
34+
/** Validate metadata and atomically save one Playwright download. */
35+
export async function saveBrowserDownload(
36+
download: PlaywrightDownload,
37+
opts: BrowserDownloadCaptureOptions = {},
38+
): Promise<BrowserDownloadResult> {
39+
const suggestedFilename = download.suggestedFilename?.() || "download.bin";
40+
const candidate: BrowserDownloadCandidate = {
41+
url: download.url?.() || "",
42+
suggestedFilename,
43+
};
44+
await opts.beforeSave?.(candidate);
45+
const saveAs = download.saveAs?.bind(download);
46+
if (!saveAs) {
47+
throw new Error("Download cannot be saved");
48+
}
49+
const requestedPath = opts.outputPath?.trim();
50+
const implicitRoot = opts.outputRoot ?? DEFAULT_DOWNLOAD_DIR;
51+
const managedPath = requestedPath || buildManagedDownloadPath(implicitRoot, suggestedFilename);
52+
const savedPath = await writeExternalFileWithinOutputRoot({
53+
rootDir: requestedPath ? opts.outputRoot : implicitRoot,
54+
path: managedPath,
55+
write: async (tempPath) => {
56+
await saveAs(tempPath);
57+
},
58+
});
59+
return { ...candidate, path: savedPath };
60+
}
61+
62+
/** Arm one page download while maintaining explicit/passive ownership depth. */
63+
export function createDownloadCaptureForPage(
64+
page: Page,
65+
state: BrowserDownloadCaptureState,
66+
timeoutMs: number,
67+
opts: BrowserDownloadCaptureOptions = {},
68+
): {
69+
armed: boolean;
70+
promise: Promise<BrowserDownloadResult>;
71+
cancel: () => void;
72+
} {
73+
// Passive action capture yields to an explicit wait/download owner. Explicit
74+
// waiters may overlap; their arm id decides which one is allowed to save.
75+
if (opts.mode !== "explicit" && state.downloadWaiterDepth > 0) {
76+
return {
77+
armed: false,
78+
promise: new Promise<BrowserDownloadResult>(() => {}),
79+
cancel: () => {},
80+
};
81+
}
82+
83+
state.downloadWaiterDepth += 1;
84+
let done = false;
85+
let depthReleased = false;
86+
let timer: NodeJS.Timeout | undefined;
87+
let handler: ((download: unknown) => void) | undefined;
88+
89+
const cleanup = () => {
90+
if (!depthReleased) {
91+
depthReleased = true;
92+
state.downloadWaiterDepth = Math.max(0, state.downloadWaiterDepth - 1);
93+
}
94+
if (timer) {
95+
clearTimeout(timer);
96+
timer = undefined;
97+
}
98+
if (handler) {
99+
page.off("download", handler as never);
100+
handler = undefined;
101+
}
102+
};
103+
104+
const promise = new Promise<BrowserDownloadResult>((resolve, reject) => {
105+
handler = (download: unknown) => {
106+
if (done) {
107+
return;
108+
}
109+
done = true;
110+
cleanup();
111+
void saveBrowserDownload(download as PlaywrightDownload, opts).then(resolve, reject);
112+
};
113+
page.on("download", handler as never);
114+
timer = setTimeout(
115+
() => {
116+
if (done) {
117+
return;
118+
}
119+
done = true;
120+
cleanup();
121+
reject(new Error(opts.timeoutMessage ?? "Timeout waiting for download"));
122+
},
123+
Math.max(1, timeoutMs),
124+
);
125+
timer.unref?.();
126+
});
127+
128+
return {
129+
armed: true,
130+
promise,
131+
cancel: () => {
132+
if (done) {
133+
return;
134+
}
135+
done = true;
136+
cleanup();
137+
},
138+
};
139+
}

extensions/browser/src/browser/pw-session.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import path from "node:path";
44
import type { Page } from "playwright-core";
55
import { afterEach, describe, expect, it, vi } from "vitest";
66
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
7+
import { createDownloadCaptureForPage } from "./pw-download-capture.js";
78
import {
89
ensurePageState,
10+
isDownloadStartingNavigationError,
911
refLocator,
1012
rememberRoleRefsForTarget,
1113
restoreRoleRefsForTarget,
@@ -39,6 +41,14 @@ function fakePage(): {
3941
handlers.set(event, list);
4042
return undefined as unknown;
4143
});
44+
const off = vi.fn((event: string, cb: (...args: unknown[]) => void) => {
45+
const list = handlers.get(event) ?? [];
46+
handlers.set(
47+
event,
48+
list.filter((handler) => handler !== cb),
49+
);
50+
return undefined as unknown;
51+
});
4252
const getByRole = vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) }));
4353
const frameLocator = vi.fn(() => ({
4454
getByRole: vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) })),
@@ -48,6 +58,7 @@ function fakePage(): {
4858

4959
const page = {
5060
on,
61+
off,
5162
getByRole,
5263
frameLocator,
5364
locator,
@@ -239,6 +250,96 @@ describe("pw-session ensurePageState", () => {
239250
expect(download.saveAs).not.toHaveBeenCalled();
240251
});
241252

253+
it("captures navigation downloads under managed paths", async () => {
254+
const { page, handlers } = fakePage();
255+
const state = ensurePageState(page);
256+
const capture = createDownloadCaptureForPage(page, state, 1_000);
257+
const saveAs = vi.fn(async (outPath: string) => {
258+
await fs.writeFile(outPath, "attachment", "utf8");
259+
});
260+
const download = {
261+
url: () => "https://example.com/export.csv",
262+
suggestedFilename: () => "export.csv",
263+
saveAs,
264+
};
265+
266+
for (const handler of handlers.get("download") ?? []) {
267+
handler(download);
268+
}
269+
270+
const result = await capture.promise;
271+
expect(result.url).toBe("https://example.com/export.csv");
272+
expect(result.suggestedFilename).toBe("export.csv");
273+
expect(path.dirname(result.path)).toBe(DEFAULT_DOWNLOAD_DIR);
274+
expect(path.basename(result.path)).toMatch(/-export\.csv$/);
275+
expect(firstSavePath(saveAs)).not.toBe(result.path);
276+
await expect(fs.readFile(result.path, "utf8")).resolves.toBe("attachment");
277+
});
278+
279+
it("validates captured navigation downloads before saving managed bytes", async () => {
280+
const { page, handlers } = fakePage();
281+
const state = ensurePageState(page);
282+
const blocked = new Error("blocked download");
283+
const beforeSave = vi.fn(async () => {
284+
throw blocked;
285+
});
286+
const capture = createDownloadCaptureForPage(page, state, 1_000, { beforeSave });
287+
const saveAs = vi.fn(async (outPath: string) => {
288+
await fs.writeFile(outPath, "blocked", "utf8");
289+
});
290+
const download = {
291+
url: () => "http://127.0.0.1:18080/export.csv",
292+
suggestedFilename: () => "export.csv",
293+
saveAs,
294+
};
295+
296+
for (const handler of handlers.get("download") ?? []) {
297+
handler(download);
298+
}
299+
300+
await expect(capture.promise).rejects.toBe(blocked);
301+
expect(beforeSave).toHaveBeenCalledWith({
302+
url: "http://127.0.0.1:18080/export.csv",
303+
suggestedFilename: "export.csv",
304+
});
305+
expect(saveAs).not.toHaveBeenCalled();
306+
});
307+
308+
it("lets explicit download owners arm while passive capture yields", () => {
309+
const { page } = fakePage();
310+
const state = ensurePageState(page);
311+
state.downloadWaiterDepth = 1;
312+
313+
const passive = createDownloadCaptureForPage(page, state, 1_000);
314+
const explicit = createDownloadCaptureForPage(page, state, 1_000, { mode: "explicit" });
315+
316+
expect(passive.armed).toBe(false);
317+
expect(explicit.armed).toBe(true);
318+
expect(state.downloadWaiterDepth).toBe(2);
319+
explicit.cancel();
320+
expect(state.downloadWaiterDepth).toBe(1);
321+
});
322+
323+
it("recognizes Playwright download-starting navigation aborts", () => {
324+
expect(isDownloadStartingNavigationError(new Error("page.goto: Download is starting"))).toBe(
325+
true,
326+
);
327+
expect(isDownloadStartingNavigationError(new Error("page.goto: net::ERR_ABORTED"))).toBe(false);
328+
expect(
329+
isDownloadStartingNavigationError(
330+
new Error("page.goto: net::ERR_ABORTED at http://127.0.0.1:3333/download"),
331+
"http://127.0.0.1:3333/download",
332+
),
333+
).toBe(true);
334+
expect(
335+
isDownloadStartingNavigationError(
336+
new Error("page.goto: net::ERR_ABORTED at http://127.0.0.1:3333/other"),
337+
"http://127.0.0.1:3333/download",
338+
),
339+
).toBe(false);
340+
expect(isDownloadStartingNavigationError(new Error("Navigation failed"))).toBe(false);
341+
});
342+
242343
it("tracks page errors and network requests (best-effort)", () => {
243344
const { page, handlers } = fakePage();
244345
const state = ensurePageState(page);

0 commit comments

Comments
 (0)