feat(browser): add downloads + response bodies

This commit is contained in:
Peter Steinberger
2026-01-12 19:40:52 +00:00
parent 3dbfe65eea
commit d4f7dc067e
8 changed files with 560 additions and 2 deletions

View File

@@ -79,6 +79,12 @@ export type BrowserActResponse = {
result?: unknown;
};
export type BrowserDownloadPayload = {
url: string;
suggestedFilename: string;
path: string;
};
export async function browserNavigate(
baseUrl: string,
opts: { url: string; targetId?: string; profile?: string },
@@ -153,6 +159,60 @@ export async function browserArmFileChooser(
);
}
export async function browserWaitForDownload(
baseUrl: string,
opts: {
path?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<{ ok: true; targetId: string; download: BrowserDownloadPayload }> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<{
ok: true;
targetId: string;
download: BrowserDownloadPayload;
}>(`${baseUrl}/wait/download${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
path: opts.path,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
}
export async function browserDownload(
baseUrl: string,
opts: {
ref: string;
path: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<{ ok: true; targetId: string; download: BrowserDownloadPayload }> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<{
ok: true;
targetId: string;
download: BrowserDownloadPayload;
}>(`${baseUrl}/download${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
ref: opts.ref,
path: opts.path,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
}
export async function browserAct(
baseUrl: string,
req: BrowserActRequest,

View File

@@ -138,3 +138,47 @@ export async function browserHighlight(
},
);
}
export async function browserResponseBody(
baseUrl: string,
opts: {
url: string;
targetId?: string;
timeoutMs?: number;
maxChars?: number;
profile?: string;
},
): Promise<{
ok: true;
targetId: string;
response: {
url: string;
status?: number;
headers?: Record<string, string>;
body: string;
truncated?: boolean;
};
}> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<{
ok: true;
targetId: string;
response: {
url: string;
status?: number;
headers?: Record<string, string>;
body: string;
truncated?: boolean;
};
}>(`${baseUrl}/response/body${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
url: opts.url,
timeoutMs: opts.timeoutMs,
maxChars: opts.maxChars,
}),
timeoutMs: 20000,
});
}

View File

@@ -15,6 +15,7 @@ export {
cookiesClearViaPlaywright,
cookiesGetViaPlaywright,
cookiesSetViaPlaywright,
downloadViaPlaywright,
dragViaPlaywright,
emulateMediaViaPlaywright,
evaluateViaPlaywright,
@@ -28,6 +29,7 @@ export {
pdfViaPlaywright,
pressKeyViaPlaywright,
resizeViewportViaPlaywright,
responseBodyViaPlaywright,
selectOptionViaPlaywright,
setDeviceViaPlaywright,
setExtraHTTPHeadersViaPlaywright,
@@ -46,5 +48,6 @@ export {
traceStartViaPlaywright,
traceStopViaPlaywright,
typeViaPlaywright,
waitForDownloadViaPlaywright,
waitForViaPlaywright,
} from "./pw-tools-core.js";

View File

@@ -63,6 +63,7 @@ type PageState = {
nextRequestId: number;
armIdUpload: number;
armIdDialog: number;
armIdDownload: number;
/**
* Role-based refs from the last role snapshot (e.g. e1/e2).
* These refs are NOT Playwright's `aria-ref` values.
@@ -103,6 +104,7 @@ export function ensurePageState(page: Page): PageState {
nextRequestId: 0,
armIdUpload: 0,
armIdDialog: 0,
armIdDownload: 0,
};
pageStates.set(page, state);

View File

@@ -1,3 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { CDPSession, Page } from "playwright-core";
import { devices as playwrightDevices } from "playwright-core";
import type { BrowserFormField } from "./client-actions-core.js";
@@ -20,6 +24,7 @@ import {
let nextUploadArmId = 0;
let nextDialogArmId = 0;
let nextDownloadArmId = 0;
function requireRef(value: unknown): string {
const raw = typeof value === "string" ? value.trim() : "";
@@ -29,6 +34,71 @@ function requireRef(value: unknown): string {
return ref;
}
function buildTempDownloadPath(fileName: string): string {
const id = crypto.randomUUID();
const safeName = fileName.trim() ? fileName.trim() : "download.bin";
return path.join("/tmp/clawdbot/downloads", `${id}-${safeName}`);
}
function normalizeTimeoutMs(timeoutMs: number | undefined, fallback: number) {
return Math.max(500, Math.min(120_000, timeoutMs ?? fallback));
}
function createPageDownloadWaiter(page: Page, timeoutMs: number) {
let done = false;
let timer: NodeJS.Timeout | undefined;
let handler: ((download: unknown) => void) | undefined;
const cleanup = () => {
if (timer) clearTimeout(timer);
timer = undefined;
if (handler) {
page.off("download", handler as never);
handler = undefined;
}
};
const promise = new Promise<unknown>((resolve, reject) => {
handler = (download: unknown) => {
if (done) return;
done = true;
cleanup();
resolve(download);
};
page.on("download", handler as never);
timer = setTimeout(() => {
if (done) return;
done = true;
cleanup();
reject(new Error("Timeout waiting for download"));
}, timeoutMs);
});
return {
promise,
cancel: () => {
if (done) return;
done = true;
cleanup();
},
};
}
function matchUrlPattern(pattern: string, url: string): boolean {
const p = pattern.trim();
if (!p) return false;
if (p === url) return true;
if (p.includes("*")) {
const escaped = p.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
const regex = new RegExp(
`^${escaped.replace(/\*\*/g, ".*").replace(/\*/g, ".*")}$`,
);
return regex.test(url);
}
return url.includes(p);
}
function toAIFriendlyError(error: unknown, selector: string): Error {
const message = error instanceof Error ? error.message : String(error);
@@ -883,7 +953,7 @@ export async function armDialogViaPlaywright(opts: {
}): Promise<void> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 120_000));
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
state.armIdDialog = nextDialogArmId += 1;
const armId = state.armIdDialog;
@@ -900,6 +970,196 @@ export async function armDialogViaPlaywright(opts: {
});
}
export async function waitForDownloadViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
path?: string;
timeoutMs?: number;
}): Promise<{
url: string;
suggestedFilename: string;
path: string;
}> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
state.armIdDownload = nextDownloadArmId += 1;
const armId = state.armIdDownload;
const waiter = createPageDownloadWaiter(page, timeout);
try {
const download = (await waiter.promise) as {
url?: () => string;
suggestedFilename?: () => string;
saveAs?: (outPath: string) => Promise<void>;
};
if (state.armIdDownload !== armId) {
throw new Error("Download was superseded by another waiter");
}
const suggested = download.suggestedFilename?.() || "download.bin";
const outPath = opts.path?.trim() || buildTempDownloadPath(suggested);
await fs.mkdir(path.dirname(outPath), { recursive: true });
await download.saveAs?.(outPath);
return {
url: download.url?.() || "",
suggestedFilename: suggested,
path: path.resolve(outPath),
};
} catch (err) {
waiter.cancel();
throw err;
}
}
export async function downloadViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
ref: string;
path: string;
timeoutMs?: number;
}): Promise<{
url: string;
suggestedFilename: string;
path: string;
}> {
const page = await getPageForTargetId(opts);
const state = ensurePageState(page);
const timeout = normalizeTimeoutMs(opts.timeoutMs, 120_000);
const ref = requireRef(opts.ref);
const outPath = String(opts.path ?? "").trim();
if (!outPath) throw new Error("path is required");
state.armIdDownload = nextDownloadArmId += 1;
const armId = state.armIdDownload;
const waiter = createPageDownloadWaiter(page, timeout);
try {
const locator = refLocator(page, ref);
try {
await locator.click({ timeout });
} catch (err) {
throw toAIFriendlyError(err, ref);
}
const download = (await waiter.promise) as {
url?: () => string;
suggestedFilename?: () => string;
saveAs?: (outPath: string) => Promise<void>;
};
if (state.armIdDownload !== armId) {
throw new Error("Download was superseded by another waiter");
}
const suggested = download.suggestedFilename?.() || "download.bin";
await fs.mkdir(path.dirname(outPath), { recursive: true });
await download.saveAs?.(outPath);
return {
url: download.url?.() || "",
suggestedFilename: suggested,
path: path.resolve(outPath),
};
} catch (err) {
waiter.cancel();
throw err;
}
}
export async function responseBodyViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
url: string;
timeoutMs?: number;
maxChars?: number;
}): Promise<{
url: string;
status?: number;
headers?: Record<string, string>;
body: string;
truncated?: boolean;
}> {
const pattern = String(opts.url ?? "").trim();
if (!pattern) throw new Error("url is required");
const maxChars =
typeof opts.maxChars === "number" && Number.isFinite(opts.maxChars)
? Math.max(1, Math.min(5_000_000, Math.floor(opts.maxChars)))
: 200_000;
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
const page = await getPageForTargetId(opts);
ensurePageState(page);
const promise = new Promise<unknown>((resolve, reject) => {
let done = false;
let timer: NodeJS.Timeout | undefined;
let handler: ((resp: unknown) => void) | undefined;
const cleanup = () => {
if (timer) clearTimeout(timer);
timer = undefined;
if (handler) page.off("response", handler as never);
};
handler = (resp: unknown) => {
if (done) return;
const r = resp as { url?: () => string };
const u = r.url?.() || "";
if (!matchUrlPattern(pattern, u)) return;
done = true;
cleanup();
resolve(resp);
};
page.on("response", handler as never);
timer = setTimeout(() => {
if (done) return;
done = true;
cleanup();
reject(
new Error(
`Response not found for url pattern "${pattern}". Run 'clawdbot browser requests' to inspect recent network activity.`,
),
);
}, timeout);
});
const resp = (await promise) as {
url?: () => string;
status?: () => number;
headers?: () => Record<string, string>;
body?: () => Promise<Buffer>;
text?: () => Promise<string>;
};
const url = resp.url?.() || "";
const status = resp.status?.();
const headers = resp.headers?.();
let bodyText = "";
try {
if (typeof resp.text === "function") {
bodyText = await resp.text();
} else if (typeof resp.body === "function") {
const buf = await resp.body();
bodyText = new TextDecoder("utf-8").decode(buf);
}
} catch (err) {
throw new Error(
`Failed to read response body for "${url}": ${String(err)}`,
);
}
const trimmed =
bodyText.length > maxChars ? bodyText.slice(0, maxChars) : bodyText;
return {
url,
status,
headers,
body: trimmed,
truncated: bodyText.length > maxChars ? true : undefined,
};
}
export async function navigateViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
@@ -930,7 +1190,7 @@ export async function waitForViaPlaywright(opts: {
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
const timeout = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000));
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
await page.waitForTimeout(Math.max(0, opts.timeMs));

View File

@@ -481,6 +481,82 @@ export function registerBrowserAgentRoutes(
}
});
app.post("/wait/download", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const out = toStringOrEmpty(body.path) || undefined;
const timeoutMs = toNumber(body.timeoutMs);
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "wait for download");
if (!pw) return;
const result = await pw.waitForDownloadViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
path: out,
timeoutMs: timeoutMs ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/download", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const ref = toStringOrEmpty(body.ref);
const out = toStringOrEmpty(body.path);
const timeoutMs = toNumber(body.timeoutMs);
if (!ref) return jsonError(res, 400, "ref is required");
if (!out) return jsonError(res, 400, "path is required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "download");
if (!pw) return;
const result = await pw.downloadViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref,
path: out,
timeoutMs: timeoutMs ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, download: result });
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.post("/response/body", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const url = toStringOrEmpty(body.url);
const timeoutMs = toNumber(body.timeoutMs);
const maxChars = toNumber(body.maxChars);
if (!url) return jsonError(res, 400, "url is required");
try {
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "response body");
if (!pw) return;
const result = await pw.responseBodyViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
url,
timeoutMs: timeoutMs ?? undefined,
maxChars: maxChars ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, response: result });
} catch (err) {
handleRouteError(ctx, res, err);
}
});
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;

View File

@@ -4,7 +4,9 @@ import {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserDownload,
browserNavigate,
browserWaitForDownload,
} from "../browser/client-actions.js";
import type { BrowserFormField } from "../browser/client-actions-core.js";
import { danger } from "../globals.js";
@@ -374,6 +376,76 @@ export function registerBrowserActionInputCommands(
}
});
browser
.command("waitfordownload")
.description("Wait for the next download (and save it)")
.argument("[path]", "Save path (default: /tmp/clawdbot/downloads/...)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
"How long to wait for the next download (default: 120000)",
(v: string) => Number(v),
)
.action(async (outPath: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
const result = await browserWaitForDownload(baseUrl, {
path: outPath?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
timeoutMs: Number.isFinite(opts.timeoutMs)
? opts.timeoutMs
: undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`downloaded: ${result.download.path}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("download")
.description("Click a ref and save the resulting download")
.argument("<ref>", "Ref id from snapshot to click")
.argument("<path>", "Save path")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
"How long to wait for the download to start (default: 120000)",
(v: string) => Number(v),
)
.action(async (ref: string, outPath: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
const result = await browserDownload(baseUrl, {
ref,
path: outPath,
targetId: opts.targetId?.trim() || undefined,
timeoutMs: Number.isFinite(opts.timeoutMs)
? opts.timeoutMs
: undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`downloaded: ${result.download.path}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("fill")
.description("Fill a form with JSON field descriptors")

View File

@@ -3,6 +3,7 @@ import { resolveBrowserControlUrl } from "../browser/client.js";
import {
browserConsoleMessages,
browserPdfSave,
browserResponseBody,
} from "../browser/client-actions.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
@@ -61,4 +62,44 @@ export function registerBrowserActionObserveCommands(
defaultRuntime.exit(1);
}
});
browser
.command("responsebody")
.description("Wait for a network response and return its body")
.argument("<url>", "URL (exact, substring, or glob like **/api)")
.option("--target-id <id>", "CDP target id (or unique prefix)")
.option(
"--timeout-ms <ms>",
"How long to wait for the response (default: 20000)",
(v: string) => Number(v),
)
.option(
"--max-chars <n>",
"Max body chars to return (default: 200000)",
(v: string) => Number(v),
)
.action(async (url: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
const result = await browserResponseBody(baseUrl, {
url,
targetId: opts.targetId?.trim() || undefined,
timeoutMs: Number.isFinite(opts.timeoutMs)
? opts.timeoutMs
: undefined,
maxChars: Number.isFinite(opts.maxChars) ? opts.maxChars : undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(result.response.body);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}