Skip to content

Commit 54d2e89

Browse files
lzyyzznlclaude
andcommitted
fix(browser): add download/wait-for-download actions so agents get notified on file download
Browser click already downloads to /tmp/openclaw/downloads but the agent has no way to know the download completed — it keeps retrying. This adds: - action=download: click element via ref + wait for download, returns file path - action=wait-for-download: wait for a pending download without a new click - param path: optional download destination path Server-side routes (POST /download, POST /wait/download) already existed in agent.act.download.ts; only the agent tool routing was missing. Closes #93250 Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 6607916 commit 54d2e89

7 files changed

Lines changed: 353 additions & 2 deletions

File tree

extensions/browser/src/browser-tool.runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ export {
3939
browserArmDialog,
4040
browserArmFileChooser,
4141
browserConsoleMessages,
42+
browserDownload,
4243
browserNavigate,
4344
browserPdfSave,
4445
browserScreenshotAction,
46+
browserWaitForDownload,
4547
} from "./browser/client-actions.js";
4648
export {
4749
browserCloseTab,

extensions/browser/src/browser-tool.schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const BROWSER_TOOL_ACTIONS = [
4747
"upload",
4848
"dialog",
4949
"act",
50+
"download",
51+
"wait-for-download",
5052
] as const;
5153

5254
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
@@ -123,6 +125,7 @@ export const BrowserToolSchema = Type.Object({
123125
interactive: Type.Optional(Type.Boolean()),
124126
compact: Type.Optional(Type.Boolean()),
125127
depth: optionalNonNegativeIntegerSchema(),
128+
path: Type.Optional(Type.String()),
126129
selector: Type.Optional(Type.String()),
127130
frame: Type.Optional(Type.String()),
128131
labels: Type.Optional(Type.Boolean()),

extensions/browser/src/browser-tool.test.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,24 @@ const browserActionsMocks = vi.hoisted(() => ({
6161
browserNavigate: vi.fn(async () => ({ ok: true })),
6262
browserPdfSave: vi.fn(async () => ({ ok: true, path: "/tmp/test.pdf" })),
6363
browserScreenshotAction: vi.fn(async () => ({ ok: true, path: "/tmp/test.png" })),
64+
browserDownload: vi.fn(async () => ({
65+
ok: true,
66+
targetId: "t1",
67+
download: {
68+
url: "https://example.com/file.pdf",
69+
suggestedFilename: "file.pdf",
70+
path: "/tmp/downloads/file.pdf",
71+
},
72+
})),
73+
browserWaitForDownload: vi.fn(async () => ({
74+
ok: true,
75+
targetId: "t1",
76+
download: {
77+
url: "https://example.com/file.pdf",
78+
suggestedFilename: "file.pdf",
79+
path: "/tmp/downloads/file.pdf",
80+
},
81+
})),
6482
}));
6583
vi.mock("./browser/client-actions.js", () => browserActionsMocks);
6684

@@ -302,6 +320,7 @@ function resetBrowserToolMocks() {
302320
browserArmFileChooser: browserActionsMocks.browserArmFileChooser as never,
303321
browserCloseTab: browserClientMocks.browserCloseTab as never,
304322
browserDoctor: browserClientMocks.browserDoctor as never,
323+
browserDownload: browserActionsMocks.browserDownload as never,
305324
browserFocusTab: browserClientMocks.browserFocusTab as never,
306325
browserNavigate: browserActionsMocks.browserNavigate as never,
307326
browserOpenTab: browserClientMocks.browserOpenTab as never,
@@ -311,6 +330,7 @@ function resetBrowserToolMocks() {
311330
browserStart: browserClientMocks.browserStart as never,
312331
browserStatus: browserClientMocks.browserStatus as never,
313332
browserStop: browserClientMocks.browserStop as never,
333+
browserWaitForDownload: browserActionsMocks.browserWaitForDownload as never,
314334
describeImageFile: toolCommonMocks.describeImageFile as never,
315335
imageResultFromFile: toolCommonMocks.imageResultFromFile as never,
316336
getRuntimeConfig: configMocks.loadConfig as never,
@@ -1854,7 +1874,7 @@ describe("browser tool upload inbound media fallback (#83544)", () => {
18541874
expect(result?.content[0]).toHaveProperty("type", "text");
18551875
});
18561876

1857-
it("rejects files outside both uploads and inbound media directories", async () => {
1877+
it("rejects files outside both uploads and incoming media directories", async () => {
18581878
pathValidationMocks.resolveExistingUploadPaths.mockResolvedValue({
18591879
ok: false as const,
18601880
error: "path outside allowed directories",
@@ -1870,3 +1890,97 @@ describe("browser tool upload inbound media fallback (#83544)", () => {
18701890
).rejects.toThrow("path outside allowed directories");
18711891
});
18721892
});
1893+
1894+
describe("browser tool download actions", () => {
1895+
registerBrowserToolAfterEachReset();
1896+
1897+
it("download action calls browserDownload with ref and path", async () => {
1898+
const tool = createBrowserTool();
1899+
const result = await tool.execute?.("call-1", {
1900+
action: "download",
1901+
target: "host",
1902+
ref: "e12",
1903+
path: "/tmp/downloads/file.pdf",
1904+
});
1905+
1906+
const opts = lastMockCallArg<{ ref?: string; path?: string }>(
1907+
browserActionsMocks.browserDownload,
1908+
1,
1909+
);
1910+
expect(opts.ref).toBe("e12");
1911+
expect(opts.path).toBe("/tmp/downloads/file.pdf");
1912+
expect(result?.content[0]?.type).toBe("text");
1913+
const { download } = JSON.parse(result?.content[0]?.text ?? "{}");
1914+
expect(download?.path).toBe("/tmp/downloads/file.pdf");
1915+
});
1916+
1917+
it("wait-for-download action calls browserWaitForDownload", async () => {
1918+
const tool = createBrowserTool();
1919+
const result = await tool.execute?.("call-1", {
1920+
action: "wait-for-download",
1921+
target: "host",
1922+
});
1923+
1924+
expect(browserActionsMocks.browserWaitForDownload).toHaveBeenCalledTimes(1);
1925+
expect(result?.content[0]?.type).toBe("text");
1926+
});
1927+
1928+
it("download action requires ref", async () => {
1929+
const tool = createBrowserTool();
1930+
1931+
await expect(
1932+
tool.execute?.("call-1", {
1933+
action: "download",
1934+
target: "host",
1935+
path: "/tmp/downloads/file.pdf",
1936+
}),
1937+
).rejects.toThrow("ref required");
1938+
});
1939+
1940+
it("download action tracks touched tab", async () => {
1941+
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
1942+
await tool.execute?.("call-1", {
1943+
action: "download",
1944+
target: "host",
1945+
ref: "e12",
1946+
path: "/tmp/downloads/file.pdf",
1947+
});
1948+
1949+
expect(sessionTabRegistryMocks.touchSessionBrowserTab).toHaveBeenCalled();
1950+
});
1951+
1952+
it("download action routes through node proxy", async () => {
1953+
mockSingleBrowserProxyNode();
1954+
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
1955+
ok: true,
1956+
payload: {
1957+
result: {
1958+
ok: true,
1959+
targetId: "node-t1",
1960+
download: {
1961+
url: "https://example.com/f",
1962+
suggestedFilename: "f.pdf",
1963+
path: "/tmp/f.pdf",
1964+
},
1965+
},
1966+
},
1967+
});
1968+
1969+
const tool = createBrowserTool();
1970+
const result = await tool.execute?.("call-1", {
1971+
action: "download",
1972+
target: "node",
1973+
ref: "e12",
1974+
path: "/tmp/downloads/file.pdf",
1975+
});
1976+
1977+
const { request } = lastNodeInvokeCall();
1978+
expect(request.params?.method).toBe("POST");
1979+
expect(request.params?.path).toBe("/download");
1980+
const body = request.params?.body as { ref?: string; path?: string } | undefined;
1981+
expect(body?.ref).toBe("e12");
1982+
expect(body?.path).toBe("/tmp/downloads/file.pdf");
1983+
expect(result?.content[0]?.type).toBe("text");
1984+
expect(browserActionsMocks.browserDownload).not.toHaveBeenCalled();
1985+
});
1986+
});

extensions/browser/src/browser-tool.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
browserArmFileChooser,
2222
browserCloseTab,
2323
browserDoctor,
24+
browserDownload,
2425
browserFocusTab,
2526
browserNavigate,
2627
browserOpenTab,
@@ -30,6 +31,7 @@ import {
3031
browserStart,
3132
browserStatus,
3233
browserStop,
34+
browserWaitForDownload,
3335
callGatewayTool,
3436
describeImageFile,
3537
getRuntimeConfig,
@@ -64,6 +66,7 @@ const browserToolDeps = {
6466
browserArmFileChooser,
6567
browserCloseTab,
6668
browserDoctor,
69+
browserDownload,
6770
browserFocusTab,
6871
browserNavigate,
6972
browserOpenTab,
@@ -73,6 +76,7 @@ const browserToolDeps = {
7376
browserStart,
7477
browserStatus,
7578
browserStop,
79+
browserWaitForDownload,
7680
describeImageFile,
7781
getRuntimeConfig,
7882
imageResultFromFile,
@@ -93,6 +97,7 @@ export const testing = {
9397
browserArmFileChooser: typeof browserArmFileChooser;
9498
browserCloseTab: typeof browserCloseTab;
9599
browserDoctor: typeof browserDoctor;
100+
browserDownload: typeof browserDownload;
96101
browserFocusTab: typeof browserFocusTab;
97102
browserNavigate: typeof browserNavigate;
98103
browserOpenTab: typeof browserOpenTab;
@@ -102,6 +107,7 @@ export const testing = {
102107
browserStart: typeof browserStart;
103108
browserStatus: typeof browserStatus;
104109
browserStop: typeof browserStop;
110+
browserWaitForDownload: typeof browserWaitForDownload;
105111
describeImageFile: typeof describeImageFile;
106112
imageResultFromFile: typeof imageResultFromFile;
107113
getRuntimeConfig: typeof getRuntimeConfig;
@@ -120,6 +126,7 @@ export const testing = {
120126
overrides?.browserArmFileChooser ?? browserArmFileChooser;
121127
browserToolDeps.browserCloseTab = overrides?.browserCloseTab ?? browserCloseTab;
122128
browserToolDeps.browserDoctor = overrides?.browserDoctor ?? browserDoctor;
129+
browserToolDeps.browserDownload = overrides?.browserDownload ?? browserDownload;
123130
browserToolDeps.browserFocusTab = overrides?.browserFocusTab ?? browserFocusTab;
124131
browserToolDeps.browserNavigate = overrides?.browserNavigate ?? browserNavigate;
125132
browserToolDeps.browserOpenTab = overrides?.browserOpenTab ?? browserOpenTab;
@@ -130,6 +137,8 @@ export const testing = {
130137
browserToolDeps.browserStart = overrides?.browserStart ?? browserStart;
131138
browserToolDeps.browserStatus = overrides?.browserStatus ?? browserStatus;
132139
browserToolDeps.browserStop = overrides?.browserStop ?? browserStop;
140+
browserToolDeps.browserWaitForDownload =
141+
overrides?.browserWaitForDownload ?? browserWaitForDownload;
133142
browserToolDeps.describeImageFile = overrides?.describeImageFile ?? describeImageFile;
134143
browserToolDeps.imageResultFromFile = overrides?.imageResultFromFile ?? imageResultFromFile;
135144
browserToolDeps.getRuntimeConfig = overrides?.getRuntimeConfig ?? getRuntimeConfig;
@@ -477,6 +486,7 @@ export function createBrowserTool(opts?: {
477486
'For profile="user" or other existing-session profiles, omit timeoutMs on act:type, evaluate, hover, scrollIntoView, drag, select, and fill; that driver rejects per-call timeout overrides for those actions.',
478487
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
479488
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc). For tab operations, targetId also accepts tabId handles (t1) and labels from action=tabs.",
489+
"For download actions, use action=download (click an element and wait for download) or action=wait-for-download (wait for a pending download). Both return the file path in the configured downloads directory.",
480490
"For multi-step browser work, login checks, stale refs, duplicate tabs, or Google Meet flows, use the bundled browser-automation skill when it is available.",
481491
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
482492
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
@@ -990,6 +1000,67 @@ export function createBrowserTool(opts?: {
9901000
touchTrackedTab(readStringValue((result as { targetId?: unknown }).targetId) ?? targetId);
9911001
return jsonResult(result);
9921002
}
1003+
case "download": {
1004+
const ref = readStringParam(params, "ref", { required: true });
1005+
const path = readStringParam(params, "path", { required: true });
1006+
const targetId = normalizeOptionalString(params.targetId);
1007+
if (proxyRequest) {
1008+
const result = await proxyRequest({
1009+
method: "POST",
1010+
path: "/download",
1011+
profile,
1012+
body: {
1013+
targetId,
1014+
ref,
1015+
path,
1016+
timeoutMs: requestedTimeoutMs,
1017+
},
1018+
timeoutMs: requestedTimeoutMs ?? 60000,
1019+
});
1020+
touchTrackedTab(
1021+
readStringValue((result as { targetId?: unknown }).targetId) ?? targetId,
1022+
);
1023+
return jsonResult(result);
1024+
}
1025+
const result = await browserToolDeps.browserDownload(baseUrl, {
1026+
targetId,
1027+
ref,
1028+
path,
1029+
timeoutMs: requestedTimeoutMs,
1030+
profile,
1031+
});
1032+
touchTrackedTab(result.targetId);
1033+
return jsonResult(result);
1034+
}
1035+
case "wait-for-download": {
1036+
const targetId = normalizeOptionalString(params.targetId);
1037+
const path = readStringParam(params, "path");
1038+
if (proxyRequest) {
1039+
const result = await proxyRequest({
1040+
method: "POST",
1041+
path: "/wait/download",
1042+
profile,
1043+
body: {
1044+
targetId,
1045+
path,
1046+
timeoutMs: requestedTimeoutMs,
1047+
},
1048+
timeoutMs: requestedTimeoutMs ?? 60000,
1049+
});
1050+
touchTrackedTab(
1051+
readStringValue((result as { targetId?: unknown }).targetId) ?? targetId,
1052+
);
1053+
return jsonResult(result);
1054+
}
1055+
const result = await browserToolDeps.browserWaitForDownload(baseUrl, {
1056+
targetId,
1057+
path,
1058+
timeoutMs: requestedTimeoutMs,
1059+
profile,
1060+
});
1061+
touchTrackedTab(result.targetId);
1062+
return jsonResult(result);
1063+
}
9931064
case "act": {
9941065
const request = readActRequestParam(params);
9951066
if (!request) {

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,57 @@ export async function browserPdfSave(
5555
timeoutMs: 20000,
5656
});
5757
}
58+
59+
type BrowserDownloadResult = {
60+
ok: true;
61+
targetId: string;
62+
download: { url: string; suggestedFilename: string; path: string };
63+
};
64+
65+
/** Click an element via ref and wait for the download to complete. */
66+
export async function browserDownload(
67+
baseUrl: string | undefined,
68+
opts: {
69+
targetId?: string;
70+
ref: string;
71+
path: string;
72+
timeoutMs?: number;
73+
profile?: string;
74+
},
75+
): Promise<BrowserDownloadResult> {
76+
const q = buildProfileQuery(opts.profile);
77+
return await fetchBrowserJson<BrowserDownloadResult>(withBaseUrl(baseUrl, `/download${q}`), {
78+
method: "POST",
79+
headers: { "Content-Type": "application/json" },
80+
body: JSON.stringify({
81+
targetId: opts.targetId,
82+
ref: opts.ref,
83+
path: opts.path,
84+
timeoutMs: opts.timeoutMs,
85+
}),
86+
timeoutMs: 60000,
87+
});
88+
}
89+
90+
/** Wait for a pending download without clicking. */
91+
export async function browserWaitForDownload(
92+
baseUrl: string | undefined,
93+
opts: {
94+
targetId?: string;
95+
path?: string;
96+
timeoutMs?: number;
97+
profile?: string;
98+
} = {},
99+
): Promise<BrowserDownloadResult> {
100+
const q = buildProfileQuery(opts.profile);
101+
return await fetchBrowserJson<BrowserDownloadResult>(withBaseUrl(baseUrl, `/wait/download${q}`), {
102+
method: "POST",
103+
headers: { "Content-Type": "application/json" },
104+
body: JSON.stringify({
105+
targetId: opts.targetId,
106+
path: opts.path,
107+
timeoutMs: opts.timeoutMs,
108+
}),
109+
timeoutMs: 60000,
110+
});
111+
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@ export {
1010
browserNavigate,
1111
browserScreenshotAction,
1212
} from "./client-actions-core.js";
13-
export { browserConsoleMessages, browserPdfSave } from "./client-actions-observe.js";
13+
export {
14+
browserConsoleMessages,
15+
browserDownload,
16+
browserPdfSave,
17+
browserWaitForDownload,
18+
} from "./client-actions-observe.js";

0 commit comments

Comments
 (0)