fix(browser): make node action downloads usable (#103328)

* fix(browser): transfer plural node downloads

* test(browser): avoid proxy test path shadowing
This commit is contained in:
Peter Steinberger
2026-07-10 05:11:22 +01:00
committed by GitHub
parent 1f12e7fc87
commit a2d5f32cef
6 changed files with 368 additions and 95 deletions
+1
View File
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- **OpenAI-compatible streamed tool calls:** execute complete native tool calls from streams that end with SSE `data: [DONE]` but omit `finish_reason`, while keeping transport EOF and visible-text cases fail-closed. (#98124, #97994) Thanks @SunnyShu0925.
- **OpenCode Zen model catalog:** refresh the provider-owned static seed for Claude Sonnet 5, Grok 4.5, Hy3 Free, Kimi K2.7 Code, and MiniMax M3 with verified routing, pricing, limits, and input capabilities, remove retired free-tier rows, and expose the same catalog through unauthenticated model listing. (#103184)
- **Managed browser launch:** surface asynchronous Chrome bootstrap and runtime spawn failures as browser errors while keeping Gateway alive, and retain process error handling through later lifecycle failures.
- **Browser node-proxy downloads:** transfer every action-produced download to the Gateway media store, align a 10 MiB per-file and 16 MiB aggregate transport budget, and rewrite plural download paths to Gateway-local files without traversing page-controlled result data.
- **Gateway startup migrations:** release the shared migration lease before exiting when the selected config changes during startup, allowing immediate retries instead of blocking readiness until the five-minute lease expires. (#103145)
- **Apple timeout recovery:** return promptly from shared operation deadlines and caller cancellation even when platform work ignores cancellation, while isolating late Gateway handshakes and cleaning up location and permission waiters. (#103066) Thanks @NianJiuZst.
- **Claude CLI warm sessions:** preserve managed stdio continuity when Claude writes no native transcript, fall back to bounded OpenClaw history only when the exact live child disappears or changes, and keep stateless runs from persisting CLI bindings. (#96841) Thanks @bradreaves.
@@ -6,12 +6,77 @@ import { parseBrowserErrorPayload, type BrowserNoDisplayErrorMetadata } from "./
/** Additive opt-in for structured browser route errors over node.invoke. */
export const BROWSER_PROXY_ERROR_ENVELOPE = "browser-v1" as const;
export const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
// 16 MiB expands to about 21.4 MiB in base64, leaving JSON/result headroom
// below the Gateway's 25 MiB WebSocket frame limit.
export const BROWSER_PROXY_MAX_TOTAL_FILE_BYTES = 16 * 1024 * 1024;
export const BROWSER_PROXY_MAX_FILES = 256;
/** Bound filesystem work even when one action emits many tiny downloads. */
export function assertBrowserProxyFileCountWithinLimit(fileCount: number): void {
if (fileCount > BROWSER_PROXY_MAX_FILES) {
throw new Error("browser proxy response exceeds 256 file limit");
}
}
/** Enforce the shared per-file and raw aggregate Browser proxy limits. */
export function assertBrowserProxyFileBytesWithinLimits(
fileBytes: number,
totalBytes: number,
): void {
if (fileBytes > BROWSER_PROXY_MAX_FILE_BYTES) {
throw new Error("browser proxy file exceeds 10 MiB limit");
}
if (totalBytes > BROWSER_PROXY_MAX_TOTAL_FILE_BYTES) {
throw new Error("browser proxy files exceed 16 MiB aggregate limit");
}
}
export type BrowserProxyFile = {
path: string;
base64: string;
mimeType?: string;
};
/** Visit the route-owned file paths that may cross the Browser node boundary. */
export function visitBrowserProxyFilePaths(
result: unknown,
visit: (filePath: string) => string | void,
): void {
if (!result || typeof result !== "object" || Array.isArray(result)) {
return;
}
const root = result as Record<string, unknown>;
const visitPath = (owner: Record<string, unknown>, key: "path" | "imagePath") => {
const filePath = owner[key];
if (typeof filePath !== "string" || !filePath.trim()) {
return;
}
const replacement = visit(filePath);
if (typeof replacement === "string") {
owner[key] = replacement;
}
};
visitPath(root, "path");
visitPath(root, "imagePath");
const download = root.download;
if (download && typeof download === "object" && !Array.isArray(download)) {
visitPath(download as Record<string, unknown>, "path");
}
// Stay shallow: evaluate results contain page-controlled objects whose
// path-like fields must never become node filesystem reads.
if (Array.isArray(root.downloads)) {
for (const entry of root.downloads) {
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
visitPath(entry as Record<string, unknown>, "path");
}
}
}
}
export type BrowserProxyErrorBody =
| { error: string }
| ({ error: string } & BrowserNoDisplayErrorMetadata);
@@ -1,9 +1,13 @@
// Browser tests cover proxy files plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { MEDIA_MAX_BYTES } from "openclaw/plugin-sdk/media-runtime";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTempHomeEnv, type TempHomeEnv } from "../../test-support.js";
import {
BROWSER_PROXY_MAX_FILE_BYTES,
BROWSER_PROXY_MAX_FILES,
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES,
} from "../browser-proxy-envelope.js";
import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "./proxy-files.js";
describe("persistBrowserProxyFiles", () => {
@@ -35,35 +39,135 @@ describe("persistBrowserProxyFiles", () => {
await expect(fs.readFile(savedPath ?? "", "utf8")).resolves.toBe("hello from browser proxy");
});
it("rejects browser proxy files that exceed the shared media size limit", async () => {
const oversized = Buffer.alloc(MEDIA_MAX_BYTES + 1, 0x41);
it("persists a file at the proxy limit above the shared media default", async () => {
const sourcePath = "/tmp/above-default.bin";
const buffer = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES, 0x41);
const mapping = await persistBrowserProxyFiles([
{
path: sourcePath,
base64: buffer.toString("base64"),
mimeType: "application/octet-stream",
},
]);
await expect(
persistBrowserProxyFiles([
{
path: "/tmp/oversized.bin",
base64: oversized.toString("base64"),
mimeType: "application/octet-stream",
},
]),
).rejects.toThrow("Media exceeds 5MB limit");
await expect(fs.stat(mapping.get(sourcePath) ?? "")).resolves.toMatchObject({
size: buffer.byteLength,
});
});
it("rejects an oversized aggregate before persisting any files", async () => {
const first = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES, 0x41);
const second = Buffer.alloc(
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES - BROWSER_PROXY_MAX_FILE_BYTES + 1,
0x42,
);
const error = await persistBrowserProxyFiles([
{
path: "/tmp/first.bin",
base64: first.toString("base64"),
mimeType: "application/octet-stream",
},
{
path: "/tmp/second.bin",
base64: second.toString("base64"),
mimeType: "application/octet-stream",
},
]).then(
() => null,
(err: unknown) => err,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("browser proxy files exceed 16 MiB aggregate limit");
await expect(
fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")),
).rejects.toHaveProperty("code", "ENOENT");
});
it("rewrites nested download paths after node file persistence", () => {
it("rejects a file above the proxy per-file limit", async () => {
const oversized = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES + 1, 0x41);
const error = await persistBrowserProxyFiles([
{
path: "/tmp/oversized.bin",
base64: oversized.toString("base64"),
mimeType: "application/octet-stream",
},
]).then(
() => null,
(err: unknown) => err,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("browser proxy file exceeds 10 MiB limit");
await expect(
fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")),
).rejects.toHaveProperty("code", "ENOENT");
});
it("rejects too many files before persisting any", async () => {
const files = Array.from({ length: BROWSER_PROXY_MAX_FILES + 1 }, (_, index) => ({
path: `/tmp/file-${index}.bin`,
base64: "",
mimeType: "application/octet-stream",
}));
await expect(persistBrowserProxyFiles(files)).rejects.toThrow(
"browser proxy response exceeds 256 file limit",
);
await expect(
fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")),
).rejects.toHaveProperty("code", "ENOENT");
});
it("rewrites explicit proxy file paths without traversing nested page data", () => {
const result = {
ok: true,
download: { path: "/tmp/openclaw/downloads/report.pdf" },
path: "/node/screenshot.png",
imagePath: "/node/snapshot.png",
download: { path: "/node/download.csv", suggestedFilename: "download.csv" },
downloads: [
{ path: "/node/first.pdf", suggestedFilename: "first.pdf" },
null,
{ path: 42 },
{ path: "/node/second.pdf", suggestedFilename: "second.pdf" },
{ path: "/node/first.pdf", suggestedFilename: "first-copy.pdf" },
],
result: {
path: "/node/page-controlled.txt",
downloads: [{ path: "/node/page-controlled-download.txt" }],
},
};
applyBrowserProxyPaths(
result,
new Map([["/tmp/openclaw/downloads/report.pdf", "/tmp/openclaw-media/report.pdf"]]),
new Map([
["/node/screenshot.png", "/gateway/screenshot.png"],
["/node/snapshot.png", "/gateway/snapshot.png"],
["/node/download.csv", "/gateway/download.csv"],
["/node/first.pdf", "/gateway/first.pdf"],
["/node/second.pdf", "/gateway/second.pdf"],
["/node/page-controlled.txt", "/gateway/should-not-rewrite.txt"],
["/node/page-controlled-download.txt", "/gateway/should-not-rewrite-download.txt"],
]),
);
expect(result.download.path).toBe("/tmp/openclaw-media/report.pdf");
expect(result).toEqual({
ok: true,
path: "/gateway/screenshot.png",
imagePath: "/gateway/snapshot.png",
download: { path: "/gateway/download.csv", suggestedFilename: "download.csv" },
downloads: [
{ path: "/gateway/first.pdf", suggestedFilename: "first.pdf" },
null,
{ path: 42 },
{ path: "/gateway/second.pdf", suggestedFilename: "second.pdf" },
{ path: "/gateway/first.pdf", suggestedFilename: "first-copy.pdf" },
],
result: {
path: "/node/page-controlled.txt",
downloads: [{ path: "/node/page-controlled-download.txt" }],
},
});
});
});
+25 -26
View File
@@ -4,45 +4,44 @@
* Persists files returned by node-hosted browser proxy calls and rewrites
* proxied result paths to local saved media paths.
*/
import {
assertBrowserProxyFileCountWithinLimit,
assertBrowserProxyFileBytesWithinLimits,
BROWSER_PROXY_MAX_FILE_BYTES,
type BrowserProxyFile,
visitBrowserProxyFilePaths,
} from "../browser-proxy-envelope.js";
import { saveMediaBuffer } from "../media/store.js";
type BrowserProxyFile = {
path: string;
base64: string;
mimeType?: string;
};
/** Persist proxy-returned files and return a remote-path to local-path map. */
export async function persistBrowserProxyFiles(files: BrowserProxyFile[] | undefined) {
if (!files || files.length === 0) {
return new Map<string, string>();
}
const mapping = new Map<string, string>();
assertBrowserProxyFileCountWithinLimit(files.length);
const decoded: Array<{ file: BrowserProxyFile; buffer: Buffer }> = [];
let totalBytes = 0;
for (const file of files) {
const buffer = Buffer.from(file.base64, "base64");
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser");
totalBytes += buffer.byteLength;
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes);
decoded.push({ file, buffer });
}
const mapping = new Map<string, string>();
for (const { file, buffer } of decoded) {
const saved = await saveMediaBuffer(
buffer,
file.mimeType,
"browser",
BROWSER_PROXY_MAX_FILE_BYTES,
);
mapping.set(file.path, saved.path);
}
return mapping;
}
/** Rewrite result.path when it points at a persisted proxy file. */
/** Rewrite every supported result path that points at a persisted proxy file. */
export function applyBrowserProxyPaths(result: unknown, mapping: Map<string, string>) {
if (!result || typeof result !== "object") {
return;
}
const obj = result as Record<string, unknown>;
if (typeof obj.path === "string" && mapping.has(obj.path)) {
obj.path = mapping.get(obj.path);
}
if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
obj.imagePath = mapping.get(obj.imagePath);
}
const download = obj.download;
if (download && typeof download === "object") {
const d = download as Record<string, unknown>;
if (typeof d.path === "string" && mapping.has(d.path)) {
d.path = mapping.get(d.path);
}
}
visitBrowserProxyFilePaths(result, (filePath) => mapping.get(filePath));
}
@@ -1,6 +1,14 @@
// Browser tests cover invoke browser plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import nodePath from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
BROWSER_PROXY_MAX_FILE_BYTES,
BROWSER_PROXY_MAX_FILES,
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES,
} from "../browser-proxy-envelope.js";
const controlServiceMocks = vi.hoisted(() => ({
createBrowserControlContext: vi.fn(() => ({ control: true })),
@@ -191,6 +199,117 @@ describe("runBrowserProxyCommand", () => {
controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true);
});
it("serializes plural action downloads without reading nested page paths", async () => {
const tempDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "openclaw-browser-proxy-action-"));
const firstPath = nodePath.join(tempDir, "first.txt");
const secondPath = nodePath.join(tempDir, "second.txt");
const nestedPagePath = nodePath.join(tempDir, "page-controlled.txt");
const result = {
ok: true,
downloads: [
{ path: firstPath, suggestedFilename: "first.txt" },
null,
{ path: 42 },
{ path: secondPath, suggestedFilename: "second.txt" },
{ path: firstPath, suggestedFilename: "first-copy.txt" },
],
result: {
path: nestedPagePath,
downloads: [{ path: nestedPagePath }],
},
};
try {
await Promise.all([
fs.writeFile(firstPath, "first browser download", "utf8"),
fs.writeFile(secondPath, "second browser download", "utf8"),
fs.writeFile(nestedPagePath, "must stay on the node", "utf8"),
]);
dispatcherMocks.dispatch.mockResolvedValueOnce({ status: 200, body: result });
const payload = JSON.parse(
await runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
) as {
result: unknown;
files?: Array<{ path: string; base64: string; mimeType?: string }>;
};
expect(payload.result).toEqual(result);
expect(
payload.files?.map((file) => ({
path: file.path,
contents: Buffer.from(file.base64, "base64").toString("utf8"),
mimeType: file.mimeType,
})),
).toEqual([
{ path: firstPath, contents: "first browser download", mimeType: "image/png" },
{ path: secondPath, contents: "second browser download", mimeType: "image/png" },
]);
expect(payload.files?.some((file) => file.path === nestedPagePath)).toBe(false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("rejects an aggregate above the proxy transport budget", async () => {
const tempDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "openclaw-browser-proxy-limit-"));
const firstPath = nodePath.join(tempDir, "first.bin");
const secondPath = nodePath.join(tempDir, "second.bin");
try {
await Promise.all([fs.writeFile(firstPath, ""), fs.writeFile(secondPath, "")]);
await Promise.all([
fs.truncate(firstPath, BROWSER_PROXY_MAX_FILE_BYTES),
fs.truncate(
secondPath,
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES - BROWSER_PROXY_MAX_FILE_BYTES + 1,
),
]);
dispatcherMocks.dispatch.mockResolvedValueOnce({
status: 200,
body: { downloads: [{ path: firstPath }, { path: secondPath }] },
});
const error = await runBrowserProxyCommand(
JSON.stringify({ method: "POST", path: "/act" }),
).then(
() => null,
(err: unknown) => err,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe(
`browser proxy file read failed for ${secondPath}: Error: browser proxy files exceed 16 MiB aggregate limit`,
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("rejects too many unique files before reading them", async () => {
dispatcherMocks.dispatch.mockResolvedValueOnce({
status: 200,
body: {
downloads: Array.from({ length: BROWSER_PROXY_MAX_FILES + 1 }, (_, index) => ({
path: `/missing/browser-download-${index}.bin`,
})),
},
});
await expect(
runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
).rejects.toThrow("browser proxy response exceeds 256 file limit");
});
it("rejects a result whose encoded node frame would exceed the transport limit", async () => {
dispatcherMocks.dispatch.mockResolvedValueOnce({
status: 200,
body: { result: "\\".repeat(7 * 1024 * 1024) },
});
await expect(
runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
).rejects.toThrow("browser proxy payload exceeds 24 MiB encoded limit");
});
it("adds profile and browser status details on ws-backed timeouts", async () => {
vi.useFakeTimers();
dispatcherMocks.dispatch
@@ -6,10 +6,13 @@ import fsPromises from "node:fs/promises";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
assertBrowserProxyFileCountWithinLimit,
assertBrowserProxyFileBytesWithinLimits,
BROWSER_PROXY_ERROR_ENVELOPE,
createBrowserProxyFailure,
type BrowserProxyEnvelope,
type BrowserProxyFile,
visitBrowserProxyFilePaths,
} from "../browser-proxy-envelope.js";
import { redactCdpUrl } from "../browser/cdp.helpers.js";
import { loadBrowserConfigForRuntimeRefresh } from "../browser/config-refresh-source.js";
@@ -37,9 +40,10 @@ type BrowserProxyParams = {
errorEnvelope?: unknown;
};
const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750;
// Leave one MiB for the fixed node.invoke.result frame around payloadJSON.
const BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES = 24 * 1024 * 1024;
function normalizeProfileAllowlist(raw?: string[]): string[] {
return Array.isArray(raw) ? normalizeStringEntries(raw) : [];
@@ -93,40 +97,36 @@ function isProfileAllowed(params: { allowProfiles: string[]; profile?: string |
function collectBrowserProxyPaths(payload: unknown): string[] {
const paths = new Set<string>();
const obj =
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : null;
if (!obj) {
return [];
}
if (typeof obj.path === "string" && obj.path.trim()) {
paths.add(obj.path.trim());
}
if (typeof obj.imagePath === "string" && obj.imagePath.trim()) {
paths.add(obj.imagePath.trim());
}
const download = obj.download;
if (download && typeof download === "object") {
const dlPath = (download as Record<string, unknown>).path;
if (typeof dlPath === "string" && dlPath.trim()) {
paths.add(dlPath.trim());
}
}
visitBrowserProxyFilePaths(payload, (filePath) => {
paths.add(filePath.trim());
assertBrowserProxyFileCountWithinLimit(paths.size);
});
return [...paths];
}
async function readBrowserProxyFile(filePath: string): Promise<BrowserProxyFile | null> {
const stat = await fsPromises.stat(filePath).catch(() => null);
if (!stat || !stat.isFile()) {
return null;
async function readBrowserProxyFiles(filePaths: string[]): Promise<BrowserProxyFile[]> {
const files: BrowserProxyFile[] = [];
let totalBytes = 0;
for (const filePath of filePaths) {
try {
const stat = await fsPromises.stat(filePath).catch(() => null);
if (!stat || !stat.isFile()) {
throw new Error("file not found");
}
assertBrowserProxyFileBytesWithinLimits(stat.size, totalBytes + stat.size);
const buffer = await fsPromises.readFile(filePath);
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength);
totalBytes += buffer.byteLength;
const mimeType = await detectMime({ buffer, filePath });
files.push({ path: filePath, base64: buffer.toString("base64"), mimeType });
} catch (err) {
throw new Error(`browser proxy file read failed for ${filePath}: ${String(err)}`, {
cause: err,
});
}
}
if (stat.size > BROWSER_PROXY_MAX_FILE_BYTES) {
throw new Error(
`browser proxy file exceeds ${Math.round(BROWSER_PROXY_MAX_FILE_BYTES / (1024 * 1024))}MB`,
);
}
const buffer = await fsPromises.readFile(filePath);
const mimeType = await detectMime({ buffer, filePath });
return { path: filePath, base64: buffer.toString("base64"), mimeType };
return files;
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- CLI JSON params are typed by the invoked method.
@@ -339,29 +339,14 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
});
}
let files: BrowserProxyFile[] | undefined;
const paths = collectBrowserProxyPaths(result);
if (paths.length > 0) {
const loaded = await Promise.all(
paths.map(async (p) => {
try {
const file = await readBrowserProxyFile(p);
if (!file) {
throw new Error("file not found");
}
return file;
} catch (err) {
throw new Error(`browser proxy file read failed for ${p}: ${String(err)}`, {
cause: err,
});
}
}),
);
if (loaded.length > 0) {
files = loaded;
}
}
const files = paths.length > 0 ? await readBrowserProxyFiles(paths) : undefined;
const payload: BrowserProxyEnvelope = files ? { result, files } : { result };
return JSON.stringify(payload);
const serialized = JSON.stringify(payload);
// Node results carry this JSON as a string inside a second JSON frame.
if (Buffer.byteLength(JSON.stringify(serialized)) > BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES) {
throw new Error("browser proxy payload exceeds 24 MiB encoded limit");
}
return serialized;
}