fix(browser): uploads fail when the browser runs on a remote node (#115291)

* fix(browser): resolve upload paths on the owning browser node, not the Gateway

When a browser session is proxied to a remote node, the upload action
previously ran resolveExistingUploadPaths on the Gateway, pinning paths
to a filesystem the node cannot see and rejecting node-local files. The
node-side /hooks/file-chooser route already re-resolves paths against
its own filesystem, so skip Gateway-local resolution whenever the
request is proxied and forward the requested paths as-is.

Fixes openclaw/openclaw#115251

* fix(browser): transfer uploads to remote browser nodes

* fix(browser): normalize upload abort errors

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
synth
2026-07-29 08:31:46 -04:00
committed by GitHub
parent 796ee27b3a
commit 151c549494
30 changed files with 2117 additions and 189 deletions

View File

@@ -45,7 +45,7 @@ These commands drive the gateway-owned `node.pair.*` store, separate from device
- `approve` scope requirements follow the pending request's declared commands:
- commandless request: `operator.pairing`
- ordinary node commands: `operator.pairing` + `operator.write`
- admin-sensitive commands (`system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `fs.listDir`, and `system.execApprovals.get/set`): `operator.pairing` + `operator.admin`
- admin-sensitive commands (`system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `fs.listDir`, and `system.execApprovals.get/set`): `operator.pairing` + `operator.admin`
- `remove` scope: `operator.pairing` can remove non-operator node rows; a device-token caller revoking its own node role on a mixed-role device additionally needs `operator.admin`.
## Invoke

View File

@@ -52,7 +52,8 @@ dispatch so authorization failures have one canonical structured response:
- `agent` needs `operator.write` for ordinary turns and `operator.admin` for
`/new` or `/reset` session lifecycle commands.
- `node.invoke` needs `operator.write` for ordinary relay commands and
`operator.admin` for `browser.proxy`, `fs.listDir`, and `terminal.upload`.
`operator.admin` for `browser.proxy`, `browser.proxy.upload.v1`, `fs.listDir`,
and `terminal.upload`.
- `talk.config` needs `operator.read`; `includeSecrets: true` also needs
`operator.talk.secrets`.
- `talk.client.*`, `talk.session.*`, `talk.speak`, and `talk.mode` need
@@ -118,11 +119,11 @@ stores relate.
`node.pair.approve` derives extra required scopes from the pending request's
command list:
| Declared commands | Required scopes |
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| none | `operator.pairing` |
| ordinary node commands | `operator.pairing` + `operator.write` |
| `system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `fs.listDir`, or `system.execApprovals.get/set` | `operator.pairing` + `operator.admin` |
| Declared commands | Required scopes |
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| none | `operator.pairing` |
| ordinary node commands | `operator.pairing` + `operator.write` |
| `system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `fs.listDir`, or `system.execApprovals.get/set` | `operator.pairing` + `operator.admin` |
Approving a node declaration records its command surface. For `computer.act`,
the node advertises that surface only after Computer Control is enabled locally;

View File

@@ -91,8 +91,8 @@ Notes:
- commandless request: `operator.pairing`
- ordinary command request: `operator.pairing` + `operator.write`
- admin-sensitive request containing `system.run`, `system.run.prepare`,
`system.which`, `browser.proxy`, `fs.listDir`, or
`system.execApprovals.get/set`: `operator.pairing` + `operator.admin`
`system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `fs.listDir`,
or `system.execApprovals.get/set`: `operator.pairing` + `operator.admin`
<Warning>
Node pairing approval records the trusted capability surface. It does **not** pin the live node command surface per node.

View File

@@ -338,11 +338,11 @@ already hold a lower operator scope.
method scope (`operator.pairing`), based on the pending request's declared
`commands` (`src/infra/node-pairing-authz.ts`):
| Declared commands | Required scopes |
| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| none | `operator.pairing` |
| ordinary commands | `operator.pairing` + `operator.write` |
| includes `system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `fs.listDir`, or `system.execApprovals.get/set` | `operator.pairing` + `operator.admin` |
| Declared commands | Required scopes |
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| none | `operator.pairing` |
| ordinary commands | `operator.pairing` + `operator.write` |
| includes `system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `fs.listDir`, or `system.execApprovals.get/set` | `operator.pairing` + `operator.admin` |
### Caps/commands/permissions (node)

View File

@@ -468,7 +468,7 @@ These rows describe the Gateway policy ceiling, not the commands implemented by
`talk.ptt.start`, `talk.ptt.stop`, `talk.ptt.cancel`, and `talk.ptt.once` are allowed by default for any node that advertises the `talk` capability or declares `talk.*` commands, independent of platform label.
Desktop host commands (`system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `mcp.tools.call.v1`, and `screen.snapshot` on macOS/Windows/Linux) are not part of the static platform-default table above. They become available once the operator approves a pairing request that declares them, after which the node's approved command set carries them forward on reconnect.
Desktop host commands (`system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `mcp.tools.call.v1`, and `screen.snapshot` on macOS/Windows/Linux) are not part of the static platform-default table above. They become available once the operator approves a pairing request that declares them, after which the node's approved command set carries them forward on reconnect.
Dangerous or privacy-heavy commands require a one-time persistent opt-in with `gateway.nodes.commands.allow`, even if a node declares them: `camera.snap`, `camera.clip`, `screen.record`, `contacts.add`, `calendar.add`, `reminders.add`, `health.summary`, `sms.send`, `sms.search`. `gateway.nodes.commands.deny` always wins over defaults and extra allowlist entries. See [HealthKit summaries](/platforms/ios-healthkit) for the iPhone consent gate and [Computer use](/nodes/computer-use) for the local enablement, pairing, capability, and tool-policy gates around desktop input.

View File

@@ -163,9 +163,12 @@ describe("browser plugin", () => {
restartPrefixes: ["browser"],
hotPrefixes: ["browser.profiles"],
});
expect(browserPluginNodeHostCommands).toHaveLength(1);
expect(browserPluginNodeHostCommands[0]?.command).toBe("browser.proxy");
expect(browserPluginNodeHostCommands.map((entry) => entry.command)).toEqual([
"browser.proxy",
"browser.proxy.upload.v1",
]);
expect(browserPluginNodeHostCommands[0]?.cap).toBe("browser");
expect(browserPluginNodeHostCommands[1]?.cap).toBe("browser");
expect(browserPluginNodeHostCommands[0]?.isAvailable?.({ config: {}, env: {} })).toBe(true);
expect(
browserPluginNodeHostCommands[0]?.isAvailable?.({
@@ -180,6 +183,8 @@ describe("browser plugin", () => {
}),
).toBe(false);
expect(typeof browserPluginNodeHostCommands[0]?.handle).toBe("function");
expect(typeof browserPluginNodeHostCommands[1]?.handle).toBe("function");
expect(typeof browserPluginNodeHostCommands[1]?.watchAvailability).toBe("function");
expect(browserSecurityAuditCollectors).toHaveLength(1);
});
@@ -377,8 +382,26 @@ describe("browser plugin", () => {
});
it("lazy-loads node host and audit runtime handlers", async () => {
const abortController = new AbortController();
await expect(browserPluginNodeHostCommands[0]?.handle("{}")).resolves.toBe("ok");
expect(runtimeApiMocks.runBrowserProxyCommand).toHaveBeenCalledWith("{}");
await expect(
browserPluginNodeHostCommands[1]?.handle("{}", undefined, {
sendNodeEvent: vi.fn(),
signal: abortController.signal,
}),
).resolves.toBe("ok");
expect(runtimeApiMocks.runBrowserProxyCommand).toHaveBeenNthCalledWith(
1,
"{}",
"browser.proxy",
undefined,
);
expect(runtimeApiMocks.runBrowserProxyCommand).toHaveBeenNthCalledWith(
2,
"{}",
"browser.proxy.upload.v1",
abortController.signal,
);
await expect(browserSecurityAuditCollectors[0]?.({} as never)).resolves.toStrictEqual([]);
expect(runtimeApiMocks.collectBrowserSecurityAuditFindings).toHaveBeenCalled();

View File

@@ -14,12 +14,16 @@ import type {
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";
import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import { isBrowserMachineOutput } from "./cli-output-mode.js";
import {
BROWSER_REQUEST_GATEWAY_METHOD,
BROWSER_REQUEST_GATEWAY_SCOPE,
} from "./src/browser-gateway-contract.js";
import {
BROWSER_PROXY_COMMAND,
BROWSER_PROXY_UPLOAD_COMMAND,
} from "./src/browser-node-commands.js";
import { parseBrowserTabToolBinding } from "./src/browser-tool-binding.js";
import { describeBrowserTool } from "./src/browser-tool-description.js";
import { BrowserToolOutputSchema, BrowserToolSchema } from "./src/browser-tool.schema.js";
@@ -30,6 +34,7 @@ import {
} from "./src/browser/system-profile-import-state.js";
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
const logger = createSubsystemLogger("browser");
const loadBrowserRegistrationRuntimeModule = createLazyRuntimeModule(
() => import("./register.runtime.js"),
@@ -162,17 +167,33 @@ export const browserPluginReload = {
};
/** Node-host command descriptors exposed by the Browser plugin. */
export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
{
command: "browser.proxy",
function createBrowserProxyNodeHostCommand(command: string): OpenClawPluginNodeHostCommand {
return {
command,
cap: "browser",
isAvailable: ({ config }) =>
config.browser?.enabled !== false && config.nodeHost?.browserProxy?.enabled !== false,
handle: async (paramsJSON) => {
handle: async (paramsJSON, _io, context) => {
const { runBrowserProxyCommand } = await loadBrowserRegistrationRuntimeModule();
return await runBrowserProxyCommand(paramsJSON);
return await runBrowserProxyCommand(paramsJSON, command, context?.signal);
},
},
...(command === BROWSER_PROXY_UPLOAD_COMMAND
? {
watchAvailability: () => {
void loadBrowserRegistrationRuntimeModule()
.then(({ ensureBrowserProxyUploadCleanup }) => ensureBrowserProxyUploadCleanup())
.catch((error: unknown) => {
logger.warn(`browser proxy upload cleanup startup failed: ${String(error)}`);
});
},
}
: {}),
};
}
export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
createBrowserProxyNodeHostCommand(BROWSER_PROXY_COMMAND),
createBrowserProxyNodeHostCommand(BROWSER_PROXY_UPLOAD_COMMAND),
];
/** Security audit collectors contributed by the Browser plugin. */

View File

@@ -3,6 +3,7 @@
* registration lazy-load these exports when browser runtime behavior is needed.
*/
export { createBrowserTool } from "./src/browser-tool.js";
export { ensureBrowserProxyUploadCleanup } from "./src/browser-proxy-upload.js";
export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js";
export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js";
export { createBrowserPluginService } from "./src/plugin-service.js";

View File

@@ -0,0 +1,10 @@
export const BROWSER_PROXY_COMMAND = "browser.proxy";
export const BROWSER_PROXY_UPLOAD_COMMAND = "browser.proxy.upload.v1";
export function browserProxyUploadUnavailableMessage(
pendingDeclaredCommands: readonly string[] | undefined,
): string {
return pendingDeclaredCommands?.includes(BROWSER_PROXY_UPLOAD_COMMAND)
? "browser node remote upload transfer is pending approval; approve the node's pending command update before retrying"
: "browser node does not support remote upload transfer; update the node or approve its pending command update before retrying";
}

View File

@@ -13,6 +13,8 @@ type BrowserNodeRequest = {
timeoutMs: number;
profile?: string;
errorEnvelope: string;
body?: unknown;
upload?: unknown;
};
};
@@ -34,13 +36,28 @@ const runtimeMocks = vi.hoisted(() => ({
fetchBrowserJson: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
}));
const uploadMocks = vi.hoisted(() => ({
isBrowserProxyUploadRequest: vi.fn(
(params: { method: string; path: string; body: unknown }) =>
params.method === "POST" &&
params.path === "/hooks/file-chooser" &&
Array.isArray((params.body as { paths?: unknown } | undefined)?.paths) &&
((params.body as { paths: unknown[] }).paths.length ?? 0) > 0,
),
prepareBrowserProxyUploadRequest: vi.fn(),
}));
vi.mock("./browser-tool.runtime.js", () => runtimeMocks);
vi.mock("./browser-proxy-upload.js", () => uploadMocks);
import { createBrowserNodeProxyRequest } from "./browser-node-proxy.js";
function createSessionProxy() {
return createBrowserNodeProxyRequest({
nodeTarget: { nodeId: "node-1" },
nodeTarget: {
nodeId: "node-1",
commands: ["browser.proxy", "browser.proxy.upload.v1"],
},
allowAutomaticHostFallback: false,
});
}
@@ -61,6 +78,10 @@ beforeEach(() => {
runtimeMocks.persistBrowserProxyFiles.mockResolvedValue(new Map<string, string>());
runtimeMocks.applyBrowserProxyPaths.mockReset();
runtimeMocks.fetchBrowserJson.mockReset();
uploadMocks.isBrowserProxyUploadRequest.mockClear();
uploadMocks.prepareBrowserProxyUploadRequest
.mockReset()
.mockImplementation(async ({ body }: { body: unknown }) => ({ body }));
});
describe("Browser node proxy nested watchdogs", () => {
@@ -140,6 +161,114 @@ describe("Browser node proxy nested watchdogs", () => {
);
});
it("sends Gateway-owned upload bytes without node-facing source paths", async () => {
const originalBody = {
paths: ["/tmp/openclaw/uploads/report.txt"],
ref: "e12",
};
const upload = {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
};
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
body: { ref: "e12" },
upload,
});
await createSessionProxy()({
method: "POST",
path: "/hooks/file-chooser",
body: originalBody,
});
expect(readGatewayCall().node.params).toMatchObject({
body: { ref: "e12" },
upload,
});
expect(readGatewayCall().node.command).toBe("browser.proxy.upload.v1");
expect(readGatewayCall().node.params.body).not.toHaveProperty("paths");
});
it("uses the original Gateway paths when an auto-selected old node lacks upload support", async () => {
const originalBody = {
paths: ["/tmp/openclaw/uploads/report.txt"],
ref: "e12",
};
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
body: { ref: "e12" },
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
});
runtimeMocks.fetchBrowserJson.mockResolvedValueOnce({ ok: true });
const proxy = createBrowserNodeProxyRequest({
nodeTarget: { nodeId: "node-1", commands: ["browser.proxy"] },
allowAutomaticHostFallback: true,
});
await proxy({
method: "POST",
path: "/hooks/file-chooser",
body: originalBody,
});
expect(runtimeMocks.fetchBrowserJson).toHaveBeenCalledWith(
"/hooks/file-chooser",
expect.objectContaining({
method: "POST",
body: JSON.stringify(originalBody),
}),
);
expect(runtimeMocks.callGatewayTool).not.toHaveBeenCalled();
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
});
it("rejects an explicit old node upload before node dispatch", async () => {
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
body: { ref: "e12" },
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
});
const proxy = createBrowserNodeProxyRequest({
nodeTarget: { nodeId: "node-1", commands: ["browser.proxy"] },
allowAutomaticHostFallback: false,
});
await expect(
proxy({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: ["/tmp/openclaw/uploads/report.txt"], ref: "e12" },
}),
).rejects.toThrow("browser node does not support remote upload transfer");
expect(runtimeMocks.callGatewayTool).not.toHaveBeenCalled();
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
});
it("explains when remote upload transfer is awaiting node command approval", async () => {
const proxy = createBrowserNodeProxyRequest({
nodeTarget: {
nodeId: "node-1",
commands: ["browser.proxy"],
pendingDeclaredCommands: ["browser.proxy", "browser.proxy.upload.v1"],
},
allowAutomaticHostFallback: false,
});
await expect(
proxy({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: ["/tmp/openclaw/uploads/report.txt"], ref: "e12" },
}),
).rejects.toThrow("remote upload transfer is pending approval");
expect(runtimeMocks.callGatewayTool).not.toHaveBeenCalled();
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
});
it("keeps default action, node, and Gateway watchdogs strictly nested", async () => {
await createSessionProxy()({ method: "GET", path: "/snapshot" });
@@ -195,7 +324,9 @@ describe("Browser node proxy nested watchdogs", () => {
});
try {
expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10);
await vi.waitFor(() => {
expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10);
});
expect(completed.size).toBe(0);
expect(runtimeMocks.persistBrowserProxyFiles).not.toHaveBeenCalled();
const invocationIds = new Set<string>();
@@ -283,7 +414,9 @@ describe("Browser node proxy nested watchdogs", () => {
const abortError = new Error("session-3 cancelled");
try {
expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10);
await vi.waitFor(() => {
expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10);
});
cancelledSession.controller.abort(abortError);
await expect(cancelledRun).rejects.toBe(abortError);
expect(runtimeMocks.persistBrowserProxyFiles).not.toHaveBeenCalled();

View File

@@ -5,6 +5,11 @@ import {
resolveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
BROWSER_PROXY_COMMAND,
BROWSER_PROXY_UPLOAD_COMMAND,
browserProxyUploadUnavailableMessage,
} from "./browser-node-commands.js";
import { isBrowserControlHostUnavailableError } from "./browser-node-fallback.js";
import {
BROWSER_PROXY_ERROR_ENVELOPE,
@@ -12,6 +17,10 @@ import {
type BrowserProxyEnvelope,
type BrowserProxySuccess,
} from "./browser-proxy-envelope.js";
import {
isBrowserProxyUploadRequest,
prepareBrowserProxyUploadRequest,
} from "./browser-proxy-upload.js";
import {
applyBrowserProxyPaths,
callGatewayTool,
@@ -24,10 +33,10 @@ const logger = createSubsystemLogger("browser");
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000;
class BrowserNodeControlHostUnavailableError extends Error {
constructor(cause: unknown) {
super("auto-selected browser node control host unavailable", { cause });
this.name = "BrowserNodeControlHostUnavailableError";
class BrowserNodeSafeFallbackError extends Error {
constructor(message: string, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause });
this.name = "BrowserNodeSafeFallbackError";
}
}
@@ -61,7 +70,9 @@ function unwrapBrowserProxyPayload(
async function callBrowserProxy(params: {
nodeId: string;
markControlHostUnavailable: boolean;
declaredCommands: readonly string[];
pendingDeclaredCommands: readonly string[];
allowAutomaticHostFallback: boolean;
method: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
@@ -82,6 +93,21 @@ async function callBrowserProxy(params: {
const gatewayTimeoutMs =
addTimerTimeoutGraceMs(nodeInvokeTimeoutMs, BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS) ??
nodeInvokeTimeoutMs;
if (
isBrowserProxyUploadRequest(params) &&
!params.declaredCommands.includes(BROWSER_PROXY_UPLOAD_COMMAND)
) {
throw new BrowserNodeSafeFallbackError(
browserProxyUploadUnavailableMessage(params.pendingDeclaredCommands),
);
}
const preparedUpload = await prepareBrowserProxyUploadRequest({
method: params.method,
path: params.path,
body: params.body,
signal: params.signal,
});
const command = preparedUpload.upload ? BROWSER_PROXY_UPLOAD_COMMAND : BROWSER_PROXY_COMMAND;
let payload: { payload?: unknown; payloadJSON?: unknown } | null;
try {
payload = await callGatewayTool<{ payload?: unknown; payloadJSON?: unknown }>(
@@ -89,7 +115,7 @@ async function callBrowserProxy(params: {
{ timeoutMs: gatewayTimeoutMs },
{
nodeId: params.nodeId,
command: "browser.proxy",
command,
// Keep the browser action, node watchdog, and Gateway RPC on distinct
// budgets so a detailed node timeout can cross both outer boundaries.
timeoutMs: nodeInvokeTimeoutMs,
@@ -97,7 +123,8 @@ async function callBrowserProxy(params: {
method: params.method,
path: params.path,
query: params.query,
body: params.body,
body: preparedUpload.body,
upload: preparedUpload.upload,
timeoutMs: proxyTimeoutMs,
profile: params.profile,
errorEnvelope: BROWSER_PROXY_ERROR_ENVELOPE,
@@ -110,8 +137,8 @@ async function callBrowserProxy(params: {
},
);
} catch (error) {
if (params.markControlHostUnavailable && isBrowserControlHostUnavailableError(error)) {
throw new BrowserNodeControlHostUnavailableError(error);
if (params.allowAutomaticHostFallback && isBrowserControlHostUnavailableError(error)) {
throw new BrowserNodeSafeFallbackError("browser node control host unavailable", error);
}
throw error;
}
@@ -146,7 +173,12 @@ async function callLocalBrowserControl(params: Parameters<BrowserProxyRequest>[0
}
export function createBrowserNodeProxyRequest(params: {
nodeTarget: { nodeId: string; label?: string };
nodeTarget: {
nodeId: string;
label?: string;
commands?: string[];
pendingDeclaredCommands?: string[];
};
allowAutomaticHostFallback: boolean;
signal?: AbortSignal;
}): BrowserProxyRequest {
@@ -164,24 +196,23 @@ export function createBrowserNodeProxyRequest(params: {
try {
const proxy = await callBrowserProxy({
nodeId: params.nodeTarget.nodeId,
markControlHostUnavailable: params.allowAutomaticHostFallback,
declaredCommands: params.nodeTarget.commands ?? [],
pendingDeclaredCommands: params.nodeTarget.pendingDeclaredCommands ?? [],
allowAutomaticHostFallback: params.allowAutomaticHostFallback,
...requestWithSignal,
});
const mapping = await persistBrowserProxyFiles(proxy.files);
applyBrowserProxyPaths(proxy.result, mapping);
return proxy.result;
} catch (error) {
if (
!params.allowAutomaticHostFallback ||
!(error instanceof BrowserNodeControlHostUnavailableError)
) {
if (!params.allowAutomaticHostFallback || !(error instanceof BrowserNodeSafeFallbackError)) {
throw error;
}
// This exact node-host failure occurs before any browser action. Retrying
// other failures could duplicate a mutating operation.
// These failures are detected before route dispatch. Retrying any later
// failure could duplicate a mutating browser action.
hostFallbackActive = true;
logger.warn(
`browser node ${params.nodeTarget.label ?? params.nodeTarget.nodeId} control host unavailable; falling back to Gateway host`,
`browser node ${params.nodeTarget.label ?? params.nodeTarget.nodeId} unavailable before dispatch (${error.message}); falling back to Gateway host`,
);
return await callLocalBrowserControl(requestWithSignal);
}

View File

@@ -5,6 +5,8 @@ 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;
/** Additive request envelope for Gateway-owned files sent to a browser node. */
export const BROWSER_PROXY_UPLOAD_ENVELOPE = "browser-upload-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
@@ -13,9 +15,12 @@ const BROWSER_PROXY_MAX_TOTAL_FILE_BYTES = 16 * 1024 * 1024;
const BROWSER_PROXY_MAX_FILES = 256;
/** Bound filesystem work even when one action emits many tiny downloads. */
export function assertBrowserProxyFileCountWithinLimit(fileCount: number): void {
export function assertBrowserProxyFileCountWithinLimit(
fileCount: number,
direction: "request" | "response" = "response",
): void {
if (fileCount > BROWSER_PROXY_MAX_FILES) {
throw new Error("browser proxy response exceeds 256 file limit");
throw new Error(`browser proxy ${direction} exceeds 256 file limit`);
}
}
@@ -38,6 +43,16 @@ export type BrowserProxyFile = {
mimeType?: string;
};
export type BrowserProxyUploadFile = {
name: string;
contentBase64: string;
};
export type BrowserProxyUploadV1 = {
envelope: typeof BROWSER_PROXY_UPLOAD_ENVELOPE;
files: BrowserProxyUploadFile[];
};
/** Visit the route-owned file paths that may cross the Browser node boundary. */
export function visitBrowserProxyFilePaths(
result: unknown,

View File

@@ -0,0 +1,477 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
BROWSER_PROXY_UPLOAD_ENVELOPE,
type BrowserProxyUploadV1,
} from "./browser-proxy-envelope.js";
import {
discardStagedBrowserProxyUpload,
ensureBrowserProxyUploadCleanup,
prepareBrowserProxyUploadRequest,
stageBrowserProxyUploadRequest,
} from "./browser-proxy-upload.js";
import { resolveExistingUploadPaths } from "./browser/paths.js";
const tempRoots: string[] = [];
async function createTempRoot(label: string): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), label));
tempRoots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
);
});
describe("browser proxy upload transport", () => {
it("reads Gateway-owned files into a versioned envelope and omits node-facing paths", async () => {
const root = await createTempRoot("openclaw-browser-proxy-gateway-");
const uploadDir = path.join(root, "uploads");
const inboundMediaDir = path.join(root, "media", "inbound");
await fs.mkdir(uploadDir, { recursive: true });
const sourcePath = path.join(uploadDir, "report.txt");
await fs.writeFile(sourcePath, "gateway bytes", "utf8");
const prepared = await prepareBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: [sourcePath], ref: "e12" },
uploadDir,
inboundMediaDir,
});
expect(prepared.body).toEqual({ ref: "e12" });
expect(prepared.upload).toEqual({
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: [
{
name: "report.txt",
contentBase64: Buffer.from("gateway bytes").toString("base64"),
},
],
});
});
it("preserves zero-byte files", async () => {
const root = await createTempRoot("openclaw-browser-proxy-empty-");
const uploadDir = path.join(root, "uploads");
await fs.mkdir(uploadDir, { recursive: true });
const sourcePath = path.join(uploadDir, "empty.txt");
await fs.writeFile(sourcePath, "");
const prepared = await prepareBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: [sourcePath] },
uploadDir,
inboundMediaDir: path.join(root, "inbound"),
});
expect(prepared.upload?.files).toEqual([{ name: "empty.txt", contentBase64: "" }]);
});
it("transfers Gateway inbound media bytes instead of resolving the URI on the node", async () => {
const root = await createTempRoot("openclaw-browser-proxy-inbound-");
const uploadDir = path.join(root, "uploads");
const inboundMediaDir = path.join(root, "media", "inbound");
await fs.mkdir(inboundMediaDir, { recursive: true });
await fs.writeFile(path.join(inboundMediaDir, "report.txt"), "gateway inbound bytes", "utf8");
const prepared = await prepareBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: ["media://inbound/report.txt"] },
uploadDir,
inboundMediaDir,
});
expect(prepared.body).toEqual({});
expect(prepared.upload?.files).toEqual([
{
name: "report.txt",
contentBase64: Buffer.from("gateway inbound bytes").toString("base64"),
},
]);
});
it("carries a prepared Gateway file through real node staging and route validation", async () => {
const gatewayRoot = await createTempRoot("openclaw-browser-proxy-gateway-root-");
const nodeRoot = await createTempRoot("openclaw-browser-proxy-node-root-");
const gatewayUploadDir = path.join(gatewayRoot, "uploads");
const nodeUploadDir = path.join(nodeRoot, "uploads");
await fs.mkdir(gatewayUploadDir, { recursive: true });
const sourcePath = path.join(gatewayUploadDir, "report.txt");
await fs.writeFile(sourcePath, "cross-host bytes", "utf8");
const prepared = await prepareBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: [sourcePath], ref: "e12" },
uploadDir: gatewayUploadDir,
inboundMediaDir: path.join(gatewayRoot, "inbound"),
});
if (!prepared.upload) {
throw new Error("expected browser proxy upload envelope");
}
const staged = await stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: prepared.body,
upload: prepared.upload,
uploadDir: nodeUploadDir,
});
const stagedPaths = (staged.body as { paths: string[] }).paths;
expect(stagedPaths).toHaveLength(1);
expect(stagedPaths[0]?.startsWith(`${nodeUploadDir}${path.sep}`)).toBe(true);
await expect(fs.readFile(stagedPaths[0] ?? "", "utf8")).resolves.toBe("cross-host bytes");
await expect(
resolveExistingUploadPaths({
requestedPaths: stagedPaths,
uploadDir: nodeUploadDir,
inboundMediaDir: path.join(nodeRoot, "inbound"),
}),
).resolves.toEqual({ ok: true, paths: stagedPaths });
await discardStagedBrowserProxyUpload(staged);
});
it("round-trips the exact 16 MiB aggregate file limit", async () => {
const gatewayRoot = await createTempRoot("openclaw-browser-proxy-limit-gateway-");
const nodeRoot = await createTempRoot("openclaw-browser-proxy-limit-node-");
const gatewayUploadDir = path.join(gatewayRoot, "uploads");
const nodeUploadDir = path.join(nodeRoot, "uploads");
await fs.mkdir(gatewayUploadDir, { recursive: true });
const firstPath = path.join(gatewayUploadDir, "first.bin");
const secondPath = path.join(gatewayUploadDir, "second.bin");
await fs.writeFile(firstPath, Buffer.alloc(10 * 1024 * 1024, 0x61));
await fs.writeFile(secondPath, Buffer.alloc(6 * 1024 * 1024, 0x62));
const prepared = await prepareBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: [firstPath, secondPath] },
uploadDir: gatewayUploadDir,
inboundMediaDir: path.join(gatewayRoot, "inbound"),
});
if (!prepared.upload) {
throw new Error("expected browser proxy upload envelope");
}
const staged = await stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: prepared.body,
upload: prepared.upload,
uploadDir: nodeUploadDir,
});
const stagedPaths = (staged.body as { paths: string[] }).paths;
await expect(fs.stat(stagedPaths[0] ?? "")).resolves.toMatchObject({
size: 10 * 1024 * 1024,
});
await expect(fs.stat(stagedPaths[1] ?? "")).resolves.toMatchObject({
size: 6 * 1024 * 1024,
});
await discardStagedBrowserProxyUpload(staged);
});
it("stages files under the node upload root with route-valid paths", async () => {
const root = await createTempRoot("openclaw-browser-proxy-node-");
const uploadDir = path.join(root, "uploads");
const upload: BrowserProxyUploadV1 = {
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: [
{ name: "report.txt", contentBase64: Buffer.from("node copy").toString("base64") },
{ name: "report.txt", contentBase64: Buffer.from("second copy").toString("base64") },
],
};
const staged = await stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { ref: "e12" },
upload,
uploadDir,
});
const stagedPaths = (staged.body as { paths: string[] }).paths;
expect(stagedPaths).toHaveLength(2);
expect(stagedPaths.map((filePath) => path.basename(filePath))).toEqual([
"report.txt",
"report.txt",
]);
await expect(fs.readFile(stagedPaths[0] ?? "", "utf8")).resolves.toBe("node copy");
await expect(fs.readFile(stagedPaths[1] ?? "", "utf8")).resolves.toBe("second copy");
await expect(
resolveExistingUploadPaths({
requestedPaths: stagedPaths,
uploadDir,
inboundMediaDir: path.join(root, "inbound"),
}),
).resolves.toEqual({ ok: true, paths: stagedPaths });
await discardStagedBrowserProxyUpload(staged);
await expect(fs.stat(staged.directory ?? "")).rejects.toHaveProperty("code", "ENOENT");
});
it("rejects ambiguous request bodies and invalid encodings before dispatch", async () => {
const root = await createTempRoot("openclaw-browser-proxy-invalid-");
const upload: BrowserProxyUploadV1 = {
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: [{ name: "report.txt", contentBase64: "not-base64" }],
};
await expect(
stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: ["/gateway/report.txt"] },
upload,
uploadDir: path.join(root, "uploads"),
}),
).rejects.toThrow("browser proxy upload body must omit paths");
await expect(
stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload,
uploadDir: path.join(root, "uploads"),
}),
).rejects.toThrow("invalid browser proxy upload encoding");
});
it("rejects malformed Gateway path arrays instead of forwarding them to the node", async () => {
const root = await createTempRoot("openclaw-browser-proxy-malformed-");
const uploadDir = path.join(root, "uploads");
await fs.mkdir(uploadDir, { recursive: true });
const sourcePath = path.join(uploadDir, "report.txt");
await fs.writeFile(sourcePath, "gateway bytes", "utf8");
await expect(
prepareBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: { paths: [sourcePath, 42] },
uploadDir,
inboundMediaDir: path.join(root, "inbound"),
}),
).rejects.toThrow("browser proxy upload paths must contain only strings");
});
it("uses portable names for Windows-reserved aliases", async () => {
const root = await createTempRoot("openclaw-browser-proxy-portable-");
const staged = await stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload: {
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: [{ name: "COM¹.txt", contentBase64: "aGVsbG8=" }],
},
uploadDir: path.join(root, "uploads"),
});
expect(path.basename((staged.body as { paths: string[] }).paths[0] ?? "")).toBe("_COM¹.txt");
await discardStagedBrowserProxyUpload(staged);
});
it("enforces retained byte and directory limits across concurrent requests", async () => {
const root = await createTempRoot("openclaw-browser-proxy-limits-");
const uploadDir = path.join(root, "uploads");
const upload: BrowserProxyUploadV1 = {
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: [{ name: "report.txt", contentBase64: Buffer.from("data").toString("base64") }],
};
const results = await Promise.allSettled([
stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload,
uploadDir,
maxRetainedBytes: 1024,
maxRetainedDirectories: 1,
}),
stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload,
uploadDir,
maxRetainedBytes: 1024,
maxRetainedDirectories: 1,
}),
]);
expect(results.map((result) => result.status).toSorted()).toEqual(["fulfilled", "rejected"]);
const rejected = results.find((result) => result.status === "rejected");
expect(rejected).toMatchObject({
reason: expect.objectContaining({
message: "RESOURCE_EXHAUSTED: browser proxy upload staging limit reached",
}),
});
const fulfilled = results.find(
(
result,
): result is PromiseFulfilledResult<
Awaited<ReturnType<typeof stageBrowserProxyUploadRequest>>
> => result.status === "fulfilled",
);
if (fulfilled) {
await discardStagedBrowserProxyUpload(fulfilled.value);
}
});
it("rejects staging when retained bytes alone exceed the quota", async () => {
const root = await createTempRoot("openclaw-browser-proxy-byte-limit-");
const uploadDir = path.join(root, "uploads");
const upload: BrowserProxyUploadV1 = {
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: [{ name: "report.txt", contentBase64: Buffer.from("data").toString("base64") }],
};
const first = await stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload,
uploadDir,
maxRetainedBytes: 40,
maxRetainedDirectories: 10,
});
await expect(
stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload,
uploadDir,
maxRetainedBytes: 40,
maxRetainedDirectories: 10,
}),
).rejects.toThrow("browser proxy upload staging limit reached");
await discardStagedBrowserProxyUpload(first);
});
it("counts the ownership marker against retained-byte admission", async () => {
const root = await createTempRoot("openclaw-browser-proxy-marker-limit-");
await expect(
stageBrowserProxyUploadRequest({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload: {
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: [{ name: "report.txt", contentBase64: Buffer.from("data").toString("base64") }],
},
uploadDir: path.join(root, "uploads"),
maxRetainedBytes: 4,
maxRetainedDirectories: 10,
}),
).rejects.toThrow("browser proxy upload staging limit reached");
});
it("removes expired staged directories during startup recovery", async () => {
const root = await createTempRoot("openclaw-browser-proxy-recovery-");
const uploadDir = path.join(root, "uploads");
const staleDirectory = path.join(uploadDir, ".proxy-uploads", "upload-stale");
await fs.mkdir(staleDirectory, { recursive: true });
await fs.writeFile(
path.join(staleDirectory, ".openclaw-browser-proxy-upload-v1"),
"openclaw-browser-proxy-upload-v1\n",
"utf8",
);
await fs.writeFile(path.join(staleDirectory, "report.txt"), "stale", "utf8");
const staleTime = new Date(Date.now() - 10_000);
await fs.utimes(staleDirectory, staleTime, staleTime);
await ensureBrowserProxyUploadCleanup({
uploadDir,
retentionMs: 1,
nowMs: Date.now(),
});
await expect(fs.stat(staleDirectory)).rejects.toHaveProperty("code", "ENOENT");
});
it("evicts the oldest retained directory during restart recovery when over quota", async () => {
const root = await createTempRoot("openclaw-browser-proxy-recovery-quota-");
const uploadDir = path.join(root, "uploads");
const stagingRoot = path.join(uploadDir, ".proxy-uploads");
const oldestDirectory = path.join(stagingRoot, "upload-oldest");
const newestDirectory = path.join(stagingRoot, "upload-newest");
for (const [directory, contents] of [
[oldestDirectory, "old"],
[newestDirectory, "new"],
] as const) {
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(
path.join(directory, ".openclaw-browser-proxy-upload-v1"),
"openclaw-browser-proxy-upload-v1\n",
"utf8",
);
await fs.writeFile(path.join(directory, "report.txt"), contents, "utf8");
}
const now = Date.now();
await fs.utimes(oldestDirectory, new Date(now - 2_000), new Date(now - 2_000));
await fs.utimes(newestDirectory, new Date(now - 1_000), new Date(now - 1_000));
await ensureBrowserProxyUploadCleanup({
uploadDir,
nowMs: now,
maxRetainedBytes: 50,
maxRetainedDirectories: 10,
});
await expect(fs.stat(oldestDirectory)).rejects.toHaveProperty("code", "ENOENT");
await expect(fs.readFile(path.join(newestDirectory, "report.txt"), "utf8")).resolves.toBe(
"new",
);
});
it("does not recover unmarked directories from the private staging root", async () => {
const root = await createTempRoot("openclaw-browser-proxy-recovery-unmarked-");
const uploadDir = path.join(root, "uploads");
const unrelatedDirectory = path.join(uploadDir, ".proxy-uploads", "upload-archive");
await fs.mkdir(unrelatedDirectory, { recursive: true });
await fs.writeFile(path.join(unrelatedDirectory, "report.txt"), "keep", "utf8");
const staleTime = new Date(Date.now() - 10_000);
await fs.utimes(unrelatedDirectory, staleTime, staleTime);
await ensureBrowserProxyUploadCleanup({
uploadDir,
retentionMs: 1,
nowMs: Date.now(),
});
await expect(fs.readFile(path.join(unrelatedDirectory, "report.txt"), "utf8")).resolves.toBe(
"keep",
);
});
it("leaves unrelated proxy requests unchanged", async () => {
const body = { paths: ["/tmp/openclaw/uploads/report.txt"] };
await expect(
prepareBrowserProxyUploadRequest({
method: "POST",
path: "/act",
body,
}),
).resolves.toEqual({ body });
await expect(
stageBrowserProxyUploadRequest({
method: "POST",
path: "/act",
body,
}),
).resolves.toEqual({ body });
});
});

View File

@@ -0,0 +1,583 @@
/**
* Browser proxy upload transport.
*
* Existing Browser upload paths are Gateway-owned. Proxied requests carry
* bounded bytes to the node, which stages private copies under its upload root.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
assertBrowserProxyFileBytesWithinLimits,
assertBrowserProxyFileCountWithinLimit,
BROWSER_PROXY_MAX_FILE_BYTES,
BROWSER_PROXY_UPLOAD_ENVELOPE,
type BrowserProxyUploadFile,
type BrowserProxyUploadV1,
} from "./browser-proxy-envelope.js";
import { DEFAULT_UPLOAD_DIR, resolveExistingUploadPaths } from "./browser/paths.js";
const logger = createSubsystemLogger("browser");
const BROWSER_PROXY_UPLOAD_ROOT_NAME = ".proxy-uploads";
const BROWSER_PROXY_UPLOAD_PREFIX = "upload-";
const BROWSER_PROXY_UPLOAD_MARKER_NAME = ".openclaw-browser-proxy-upload-v1";
const BROWSER_PROXY_UPLOAD_MARKER_CONTENT = "openclaw-browser-proxy-upload-v1\n";
const BROWSER_PROXY_UPLOAD_RETENTION_MS = 24 * 60 * 60 * 1000;
const BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS = 60 * 60 * 1000;
const BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES = 256 * 1024 * 1024;
const BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES = 64;
const BROWSER_PROXY_MAX_ENCODED_FILE_LENGTH = Math.ceil(BROWSER_PROXY_MAX_FILE_BYTES / 3) * 4;
const MAX_STAGED_NAME_BYTES = 180;
const PORTABLE_NAME_FORBIDDEN = new Set(["<", ">", ":", '"', "/", "\\", "|", "?", "*", "%", "!"]);
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu;
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
const recoveryPromises = new Map<string, Promise<void>>();
const recoveryRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
const stagingLocks = new Map<string, Promise<void>>();
type PreparedBrowserProxyUploadRequest = {
body: unknown;
upload?: BrowserProxyUploadV1;
};
type StagedBrowserProxyUploadRequest = {
body: unknown;
directory?: string;
};
type StagedUploadLimits = {
maxRetainedBytes: number;
maxRetainedDirectories: number;
};
type OwnedStagedUpload = {
bytes: number;
directory: string;
mtimeMs: number;
};
function isFileChooserRequest(method: string, requestPath: string): boolean {
return (
method.toUpperCase() === "POST" &&
`/${requestPath.trim().replace(/^\/+/u, "")}` === "/hooks/file-chooser"
);
}
/** Identify upload requests before reading files or applying transport-only limits. */
export function isBrowserProxyUploadRequest(params: {
method: string;
path: string;
body?: unknown;
}): boolean {
if (!isFileChooserRequest(params.method, params.path)) {
return false;
}
const body = asRecord(params.body);
return Boolean(body && Array.isArray(body.paths) && body.paths.length > 0);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readUploadPaths(body: Record<string, unknown>): string[] | null {
if (!Array.isArray(body.paths) || body.paths.length === 0) {
return null;
}
if (!body.paths.every((entry): entry is string => typeof entry === "string")) {
throw new Error("browser proxy upload paths must contain only strings");
}
return body.paths;
}
async function readBrowserProxyUploadFiles(
paths: string[],
signal?: AbortSignal,
): Promise<BrowserProxyUploadFile[]> {
assertBrowserProxyFileCountWithinLimit(paths.length, "request");
const files: BrowserProxyUploadFile[] = [];
let totalBytes = 0;
for (const filePath of paths) {
signal?.throwIfAborted();
const stat = await fs.stat(filePath).catch(() => null);
if (!stat?.isFile()) {
throw new Error(`browser proxy upload file not found: ${filePath}`);
}
assertBrowserProxyFileBytesWithinLimits(stat.size, totalBytes + stat.size);
const buffer = await fs.readFile(filePath, signal ? { signal } : undefined);
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength);
totalBytes += buffer.byteLength;
files.push({
name: path.basename(filePath),
contentBase64: buffer.toString("base64"),
});
}
return files;
}
/** Build a node-only upload envelope while retaining the original body for host fallback. */
export async function prepareBrowserProxyUploadRequest(params: {
method: string;
path: string;
body: unknown;
uploadDir?: string;
inboundMediaDir?: string;
signal?: AbortSignal;
}): Promise<PreparedBrowserProxyUploadRequest> {
params.signal?.throwIfAborted();
if (!isFileChooserRequest(params.method, params.path)) {
return { body: params.body };
}
const body = asRecord(params.body);
if (!body) {
return { body: params.body };
}
const requestedPaths = readUploadPaths(body);
if (!requestedPaths) {
return { body: params.body };
}
assertBrowserProxyFileCountWithinLimit(requestedPaths.length, "request");
const resolved = await resolveExistingUploadPaths({
requestedPaths,
...(params.uploadDir ? { uploadDir: params.uploadDir } : {}),
...(params.inboundMediaDir ? { inboundMediaDir: params.inboundMediaDir } : {}),
});
if (!resolved.ok) {
throw new Error(resolved.error);
}
const upload: BrowserProxyUploadV1 = {
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
files: await readBrowserProxyUploadFiles(resolved.paths, params.signal),
};
const { paths: _paths, ...bodyWithoutPaths } = body;
return { body: bodyWithoutPaths, upload };
}
function truncateUtf8(value: string, maxBytes: number): string {
let result = "";
let bytes = 0;
for (const character of value) {
const nextBytes = Buffer.byteLength(character, "utf8");
if (bytes + nextBytes > maxBytes) {
break;
}
result += character;
bytes += nextBytes;
}
return result;
}
function sanitizeUploadName(name: string): string {
const basename = path.posix.basename(name.replaceAll("\\", "/"));
const cleaned = Array.from(basename, (character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x1f || codePoint === 0x7f || PORTABLE_NAME_FORBIDDEN.has(character)
? "_"
: character;
})
.join("")
.trim()
.replace(/[. ]+$/u, "");
const portable = WINDOWS_RESERVED_NAME.test(cleaned) ? `_${cleaned}` : cleaned;
const safe = portable && portable !== "." && portable !== ".." ? portable : "upload";
return truncateUtf8(safe, MAX_STAGED_NAME_BYTES) || "upload";
}
function decodedBase64Size(value: string): number {
if (value.length % 4 !== 0) {
throw new Error("INVALID_REQUEST: invalid browser proxy upload encoding");
}
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
return (value.length / 4) * 3 - padding;
}
function decodeUploadFile(file: BrowserProxyUploadFile, totalBytes: number): Buffer {
if (
!file ||
typeof file.name !== "string" ||
!file.name.trim() ||
typeof file.contentBase64 !== "string" ||
file.contentBase64.length > BROWSER_PROXY_MAX_ENCODED_FILE_LENGTH
) {
throw new Error("INVALID_REQUEST: invalid browser proxy upload file");
}
const estimatedBytes = decodedBase64Size(file.contentBase64);
assertBrowserProxyFileBytesWithinLimits(estimatedBytes, totalBytes + estimatedBytes);
const buffer = Buffer.from(file.contentBase64, "base64");
if (buffer.toString("base64") !== file.contentBase64) {
throw new Error("INVALID_REQUEST: invalid browser proxy upload encoding");
}
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength);
return buffer;
}
async function removeStagedUpload(directory: string): Promise<void> {
const timer = cleanupTimers.get(directory);
if (timer) {
clearTimeout(timer);
cleanupTimers.delete(directory);
}
try {
await fs.rm(directory, { recursive: true, force: true });
} catch (error) {
logger.warn(`browser proxy upload cleanup failed; retrying: ${String(error)}`);
scheduleCleanup(directory, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS);
}
}
async function readDirectoryBytes(directory: string, signal?: AbortSignal): Promise<number> {
signal?.throwIfAborted();
let entries;
try {
entries = await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return 0;
}
throw error;
}
let totalBytes = 0;
for (const entry of entries) {
signal?.throwIfAborted();
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
totalBytes += await readDirectoryBytes(entryPath, signal);
continue;
}
if (!entry.isFile()) {
continue;
}
const stat = await fs.stat(entryPath).catch(() => null);
totalBytes += stat?.isFile() ? stat.size : 0;
}
return totalBytes;
}
async function readOwnedStagedUploads(
stagingRoot: string,
signal?: AbortSignal,
): Promise<OwnedStagedUpload[]> {
signal?.throwIfAborted();
let entries;
try {
entries = await fs.readdir(stagingRoot, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
const uploads = await Promise.all(
entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith(BROWSER_PROXY_UPLOAD_PREFIX))
.map(async (entry): Promise<OwnedStagedUpload | null> => {
signal?.throwIfAborted();
const directory = path.join(stagingRoot, entry.name);
const stat = await fs.stat(directory).catch(() => null);
if (!stat?.isDirectory()) {
return null;
}
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
return null;
}
const marker = await fs
.readFile(path.join(directory, BROWSER_PROXY_UPLOAD_MARKER_NAME), "utf8")
.catch(() => null);
if (marker !== BROWSER_PROXY_UPLOAD_MARKER_CONTENT) {
return null;
}
return {
bytes: await readDirectoryBytes(directory, signal),
directory,
mtimeMs: stat.mtimeMs,
};
}),
);
return uploads.filter((upload): upload is OwnedStagedUpload => upload !== null);
}
function scheduleCleanup(directory: string, delayMs: number): void {
if (cleanupTimers.has(directory)) {
return;
}
const timer = setTimeout(
() => {
cleanupTimers.delete(directory);
void removeStagedUpload(directory);
},
Math.max(0, delayMs),
);
cleanupTimers.set(directory, timer);
timer.unref?.();
}
async function recoverStagedUploads(params: {
uploadDir: string;
retentionMs: number;
nowMs: number;
limits: StagedUploadLimits;
}): Promise<void> {
const { uploadDir, retentionMs, nowMs, limits } = params;
const stagingRoot = path.join(uploadDir, BROWSER_PROXY_UPLOAD_ROOT_NAME);
const retained: OwnedStagedUpload[] = [];
for (const upload of await readOwnedStagedUploads(stagingRoot)) {
if (retentionMs - Math.max(0, nowMs - upload.mtimeMs) <= 0) {
await removeStagedUpload(upload.directory);
} else {
retained.push(upload);
}
}
// A restarted node has no surviving Browser request that can still own these
// copies, so oldest-first eviction safely restores the bounded retention budget.
retained.sort((left, right) => left.mtimeMs - right.mtimeMs);
let totalBytes = retained.reduce((total, upload) => total + upload.bytes, 0);
while (retained.length > limits.maxRetainedDirectories || totalBytes > limits.maxRetainedBytes) {
const oldest = retained.shift();
if (!oldest) {
break;
}
totalBytes -= oldest.bytes;
await removeStagedUpload(oldest.directory);
}
for (const upload of retained) {
const remaining = retentionMs - Math.max(0, nowMs - upload.mtimeMs);
scheduleCleanup(upload.directory, remaining);
}
}
function clearRecoveryRetry(uploadDir: string): void {
const timer = recoveryRetryTimers.get(uploadDir);
if (!timer) {
return;
}
clearTimeout(timer);
recoveryRetryTimers.delete(uploadDir);
}
function scheduleRecoveryRetry(uploadDir: string, retentionMs: number): void {
if (recoveryRetryTimers.has(uploadDir)) {
return;
}
const timer = setTimeout(() => {
recoveryRetryTimers.delete(uploadDir);
recoveryPromises.delete(uploadDir);
void ensureBrowserProxyUploadCleanup({ uploadDir, retentionMs });
}, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS);
recoveryRetryTimers.set(uploadDir, timer);
timer.unref?.();
}
async function runRecovery(params: {
uploadDir: string;
retentionMs: number;
nowMs: number;
limits: StagedUploadLimits;
}): Promise<void> {
try {
await recoverStagedUploads(params);
clearRecoveryRetry(params.uploadDir);
} catch (error) {
logger.warn(`browser proxy upload recovery failed; retrying: ${String(error)}`);
scheduleRecoveryRetry(params.uploadDir, params.retentionMs);
}
}
/** Restores cleanup timers for staged uploads left by a previous node process. */
export function ensureBrowserProxyUploadCleanup(options?: {
uploadDir?: string;
retentionMs?: number;
nowMs?: number;
maxRetainedBytes?: number;
maxRetainedDirectories?: number;
}): Promise<void> {
const uploadDir = options?.uploadDir ?? DEFAULT_UPLOAD_DIR;
const retentionMs = options?.retentionMs ?? BROWSER_PROXY_UPLOAD_RETENTION_MS;
const nowMs = options?.nowMs ?? Date.now();
const limits = {
maxRetainedBytes: options?.maxRetainedBytes ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES,
maxRetainedDirectories:
options?.maxRetainedDirectories ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES,
};
if (
options?.retentionMs !== undefined ||
options?.nowMs !== undefined ||
options?.maxRetainedBytes !== undefined ||
options?.maxRetainedDirectories !== undefined
) {
return runRecovery({ uploadDir, retentionMs, nowMs, limits });
}
const existing = recoveryPromises.get(uploadDir);
if (existing) {
return existing;
}
const recovery = runRecovery({ uploadDir, retentionMs, nowMs, limits }).finally(() => {
if (recoveryRetryTimers.has(uploadDir)) {
recoveryPromises.delete(uploadDir);
}
});
recoveryPromises.set(uploadDir, recovery);
return recovery;
}
async function waitForStagingLock(previous: Promise<void>, signal?: AbortSignal): Promise<void> {
if (!signal) {
await previous;
return;
}
signal.throwIfAborted();
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_, reject) => {
onAbort = () => {
try {
signal.throwIfAborted();
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
}
};
signal.addEventListener("abort", onAbort, { once: true });
});
try {
await Promise.race([previous, aborted]);
} finally {
if (onAbort) {
signal.removeEventListener("abort", onAbort);
}
}
}
async function withStagingLock<T>(
uploadDir: string,
task: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const previous = stagingLocks.get(uploadDir) ?? Promise.resolve();
let release = () => {};
const current = new Promise<void>((resolve) => {
release = resolve;
});
const tail = previous.then(() => current);
stagingLocks.set(uploadDir, tail);
try {
await waitForStagingLock(previous, signal);
return await task();
} finally {
release();
if (stagingLocks.get(uploadDir) === tail) {
stagingLocks.delete(uploadDir);
}
}
}
function validateUploadEnvelope(upload: BrowserProxyUploadV1): BrowserProxyUploadFile[] {
if (
!upload ||
upload.envelope !== BROWSER_PROXY_UPLOAD_ENVELOPE ||
!Array.isArray(upload.files) ||
upload.files.length === 0
) {
throw new Error("INVALID_REQUEST: invalid browser proxy upload envelope");
}
assertBrowserProxyFileCountWithinLimit(upload.files.length, "request");
return upload.files;
}
/** Stage a validated upload envelope under the node's managed Browser upload root. */
export async function stageBrowserProxyUploadRequest(params: {
method: string;
path: string;
body: unknown;
upload?: BrowserProxyUploadV1;
uploadDir?: string;
maxRetainedBytes?: number;
maxRetainedDirectories?: number;
signal?: AbortSignal;
}): Promise<StagedBrowserProxyUploadRequest> {
params.signal?.throwIfAborted();
if (params.upload === undefined) {
return { body: params.body };
}
if (!isFileChooserRequest(params.method, params.path)) {
throw new Error("INVALID_REQUEST: browser proxy upload requires the file chooser route");
}
const body = asRecord(params.body);
if (!body || Object.hasOwn(body, "paths")) {
throw new Error("INVALID_REQUEST: browser proxy upload body must omit paths");
}
const files = validateUploadEnvelope(params.upload);
const uploadDir = params.uploadDir ?? DEFAULT_UPLOAD_DIR;
const stagingRoot = path.join(uploadDir, BROWSER_PROXY_UPLOAD_ROOT_NAME);
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
params.signal?.throwIfAborted();
await ensureBrowserProxyUploadCleanup({ uploadDir });
params.signal?.throwIfAborted();
const decodedFiles: Array<{ buffer: Buffer; name: string }> = [];
let fileBytes = 0;
for (const file of files) {
const buffer = decodeUploadFile(file, fileBytes);
fileBytes += buffer.byteLength;
decodedFiles.push({ buffer, name: file.name });
}
const retainedRequestBytes = fileBytes + Buffer.byteLength(BROWSER_PROXY_UPLOAD_MARKER_CONTENT);
const limits = {
maxRetainedBytes: params.maxRetainedBytes ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES,
maxRetainedDirectories:
params.maxRetainedDirectories ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES,
};
return await withStagingLock(
uploadDir,
async () => {
params.signal?.throwIfAborted();
const retained = await readOwnedStagedUploads(stagingRoot, params.signal);
const retainedBytes = retained.reduce((total, upload) => total + upload.bytes, 0);
if (
retained.length >= limits.maxRetainedDirectories ||
retainedBytes + retainedRequestBytes > limits.maxRetainedBytes
) {
throw new Error("RESOURCE_EXHAUSTED: browser proxy upload staging limit reached");
}
const directory = await fs.mkdtemp(path.join(stagingRoot, BROWSER_PROXY_UPLOAD_PREFIX));
const stagedPaths: string[] = [];
try {
await fs.writeFile(
path.join(directory, BROWSER_PROXY_UPLOAD_MARKER_NAME),
BROWSER_PROXY_UPLOAD_MARKER_CONTENT,
{ flag: "wx", mode: 0o600, signal: params.signal },
);
for (const [index, file] of decodedFiles.entries()) {
params.signal?.throwIfAborted();
const fileDirectory = path.join(directory, String(index));
await fs.mkdir(fileDirectory, { mode: 0o700 });
params.signal?.throwIfAborted();
const filePath = path.join(fileDirectory, sanitizeUploadName(file.name));
await fs.writeFile(filePath, file.buffer, {
flag: "wx",
mode: 0o600,
signal: params.signal,
});
stagedPaths.push(filePath);
}
} catch (error) {
await removeStagedUpload(directory);
throw error;
}
scheduleCleanup(directory, BROWSER_PROXY_UPLOAD_RETENTION_MS);
return {
body: { ...body, paths: stagedPaths },
directory,
};
},
params.signal,
);
}
/** Remove staged copies after a request fails before Browser ownership is established. */
export async function discardStagedBrowserProxyUpload(
staged: StagedBrowserProxyUploadRequest,
): Promise<void> {
if (staged.directory) {
await removeStagedUpload(staged.directory);
}
}

View File

@@ -356,7 +356,7 @@ function mockSingleBrowserProxyNode() {
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
commands: ["browser.proxy", "browser.proxy.upload.v1"],
},
]);
}
@@ -3521,5 +3521,35 @@ describe("browser tool upload inbound media fallback (#83544)", () => {
}),
).rejects.toThrow("path outside allowed directories");
});
it("surfaces pending remote-upload approval from the selected node", async () => {
const inboundPath = "/home/user/.openclaw/media/inbound/report.pdf";
pathValidationMocks.resolveExistingUploadPaths.mockResolvedValue({
ok: true,
paths: [inboundPath],
});
nodesUtilsMocks.listNodes.mockResolvedValue([
{
nodeId: "node-1",
displayName: "Browser Node",
connected: true,
caps: ["browser"],
commands: ["browser.proxy"],
approvalState: "pending-reapproval",
pendingDeclaredCommands: ["browser.proxy", "browser.proxy.upload.v1"],
},
]);
const tool = createBrowserTool();
await expect(
tool.execute?.("call-upload-pending", {
action: "upload",
target: "node",
paths: [inboundPath],
ref: "file-input-1",
}),
).rejects.toThrow("remote upload transfer is pending approval");
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View File

@@ -1,3 +1,4 @@
import { BROWSER_PROXY_COMMAND } from "./browser-node-commands.js";
/**
* Browser agent tool registration.
*
@@ -213,12 +214,14 @@ function readActRequestParam(params: Record<string, unknown>) {
type BrowserNodeTarget = {
nodeId: string;
label?: string;
commands: string[];
pendingDeclaredCommands: string[];
};
function isBrowserNode(node: NodeListNode) {
const caps = Array.isArray(node.caps) ? node.caps : [];
const commands = Array.isArray(node.commands) ? node.commands : [];
return caps.includes("browser") || commands.includes("browser.proxy");
return caps.includes("browser") || commands.includes(BROWSER_PROXY_COMMAND);
}
async function resolveBrowserNodeTarget(params: {
@@ -268,7 +271,14 @@ async function resolveBrowserNodeTarget(params: {
allowCompactDisplayName: true,
});
const node = browserNodes.find((entry) => entry.nodeId === nodeId);
return { nodeId, label: node?.displayName ?? node?.remoteIp ?? nodeId };
return {
nodeId,
label: node?.displayName ?? node?.remoteIp ?? nodeId,
commands: Array.isArray(node?.commands) ? node.commands : [],
pendingDeclaredCommands: Array.isArray(node?.pendingDeclaredCommands)
? node.pendingDeclaredCommands
: [],
};
}
const selected = selectDefaultNodeFromList(browserNodes, {
@@ -281,6 +291,10 @@ async function resolveBrowserNodeTarget(params: {
return {
nodeId: selected.nodeId,
label: selected.displayName ?? selected.remoteIp ?? selected.nodeId,
commands: Array.isArray(selected.commands) ? selected.commands : [],
pendingDeclaredCommands: Array.isArray(selected.pendingDeclaredCommands)
? selected.pendingDeclaredCommands
: [],
};
}
throw new Error(
@@ -296,6 +310,10 @@ async function resolveBrowserNodeTarget(params: {
return {
nodeId: selected.nodeId,
label: selected.displayName ?? selected.remoteIp ?? selected.nodeId,
commands: Array.isArray(selected.commands) ? selected.commands : [],
pendingDeclaredCommands: Array.isArray(selected.pendingDeclaredCommands)
? selected.pendingDeclaredCommands
: [],
};
}
return null;

View File

@@ -7,11 +7,33 @@ const {
isNodeCommandAllowedMock,
resolveNodeCommandAllowlistMock,
startBrowserControlServiceFromConfigMock,
} = vi.hoisted(() => ({
loadConfigMock: vi.fn(),
isNodeCommandAllowedMock: vi.fn(),
resolveNodeCommandAllowlistMock: vi.fn(),
startBrowserControlServiceFromConfigMock: vi.fn(async () => false),
createBrowserControlContextMock,
createBrowserRouteDispatcherMock,
dispatchBrowserRouteMock,
} = vi.hoisted(() => {
const dispatchMock = vi.fn();
return {
loadConfigMock: vi.fn(),
isNodeCommandAllowedMock: vi.fn(),
resolveNodeCommandAllowlistMock: vi.fn(),
startBrowserControlServiceFromConfigMock: vi.fn(async () => false),
createBrowserControlContextMock: vi.fn(() => ({ control: true })),
dispatchBrowserRouteMock: dispatchMock,
createBrowserRouteDispatcherMock: vi.fn(() => ({
dispatch: dispatchMock,
})),
};
});
const uploadMocks = vi.hoisted(() => ({
isBrowserProxyUploadRequest: vi.fn(
(params: { method: string; path: string; body: unknown }) =>
params.method === "POST" &&
params.path === "/hooks/file-chooser" &&
Array.isArray((params.body as { paths?: unknown } | undefined)?.paths) &&
((params.body as { paths: unknown[] }).paths.length ?? 0) > 0,
),
prepareBrowserProxyUploadRequest: vi.fn(),
}));
vi.mock("../core-api.js", async () => {
@@ -19,9 +41,13 @@ vi.mock("../core-api.js", async () => {
return {
...actual,
startBrowserControlServiceFromConfig: startBrowserControlServiceFromConfigMock,
createBrowserControlContext: createBrowserControlContextMock,
createBrowserRouteDispatcher: createBrowserRouteDispatcherMock,
};
});
vi.mock("../browser-proxy-upload.js", () => uploadMocks);
vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", async () => {
const actual = await vi.importActual<
typeof import("openclaw/plugin-sdk/runtime-config-snapshot")
@@ -52,6 +78,7 @@ type TestNode = {
displayName?: string;
caps?: string[];
commands?: string[];
declaredCommands?: string[];
platform?: string;
};
@@ -65,7 +92,7 @@ function createContext(invokeResult?: unknown, connectedNodes?: TestNode[]) {
{
nodeId: "node-1",
caps: ["browser"],
commands: ["browser.proxy"],
commands: ["browser.proxy", "browser.proxy.upload.v1"],
platform: "linux",
},
],
@@ -120,7 +147,14 @@ describe("browser.request profile selection", () => {
});
resolveNodeCommandAllowlistMock.mockReturnValue([]);
isNodeCommandAllowedMock.mockReturnValue({ ok: true });
startBrowserControlServiceFromConfigMock.mockClear();
startBrowserControlServiceFromConfigMock.mockReset().mockResolvedValue(false);
createBrowserControlContextMock.mockClear();
createBrowserRouteDispatcherMock.mockClear();
dispatchBrowserRouteMock.mockReset();
uploadMocks.isBrowserProxyUploadRequest.mockClear();
uploadMocks.prepareBrowserProxyUploadRequest
.mockReset()
.mockImplementation(async ({ body }: { body: unknown }) => ({ body }));
});
it("forces system-profile import host-local even when a browser node is connected", async () => {
@@ -299,6 +333,145 @@ describe("browser.request profile selection", () => {
expect(firstRespondCall(respond)[2]?.message).toBe("browser control is disabled");
});
it("sends Gateway-owned upload bytes without forwarding source paths", async () => {
const upload = {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
};
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
body: { ref: "e12" },
upload,
});
const { respond, nodeRegistry } = await runBrowserRequest({
method: "POST",
path: "/hooks/file-chooser",
body: {
paths: ["/tmp/openclaw/uploads/report.txt"],
ref: "e12",
},
});
expect(invokeParams(nodeRegistry).params).toMatchObject({
body: { ref: "e12" },
upload,
});
expect(invokeParams(nodeRegistry).command).toBe("browser.proxy.upload.v1");
expect(invokeParams(nodeRegistry).params?.body).not.toHaveProperty("paths");
expect(firstRespondCall(respond)[0]).toBe(true);
});
it("uses the original Gateway paths when an auto-selected old node lacks upload support", async () => {
const originalBody = {
paths: ["/tmp/openclaw/uploads/report.txt"],
ref: "e12",
};
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
body: { ref: "e12" },
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
});
startBrowserControlServiceFromConfigMock.mockResolvedValueOnce(true);
dispatchBrowserRouteMock.mockResolvedValueOnce({ status: 200, body: { ok: true } });
const { respond, nodeRegistry } = await runBrowserRequest(
{
method: "POST",
path: "/hooks/file-chooser",
body: originalBody,
},
undefined,
[
{
nodeId: "node-1",
caps: ["browser"],
commands: ["browser.proxy"],
platform: "linux",
},
],
);
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
expect(dispatchBrowserRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
path: "/hooks/file-chooser",
body: originalBody,
}),
);
expect(firstRespondCall(respond)).toEqual([true, { ok: true }]);
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
});
it("rejects a configured old node upload before node dispatch", async () => {
loadConfigMock.mockReturnValue({
gateway: { nodes: { browser: { mode: "auto", node: "node-1" } } },
});
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
body: { ref: "e12" },
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
});
const { respond, nodeRegistry } = await runBrowserRequest(
{
method: "POST",
path: "/hooks/file-chooser",
body: { paths: ["/tmp/openclaw/uploads/report.txt"], ref: "e12" },
},
undefined,
[
{
nodeId: "node-1",
caps: ["browser"],
commands: ["browser.proxy"],
platform: "linux",
},
],
);
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
expect(startBrowserControlServiceFromConfigMock).not.toHaveBeenCalled();
expect(firstRespondCall(respond)[2]?.message).toContain(
"browser node does not support remote upload transfer",
);
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
});
it("explains when configured-node upload support is awaiting approval", async () => {
loadConfigMock.mockReturnValue({
gateway: { nodes: { browser: { mode: "auto", node: "node-1" } } },
});
const { respond, nodeRegistry } = await runBrowserRequest(
{
method: "POST",
path: "/hooks/file-chooser",
body: { paths: ["/tmp/openclaw/uploads/report.txt"], ref: "e12" },
},
undefined,
[
{
nodeId: "node-1",
caps: ["browser"],
commands: ["browser.proxy"],
declaredCommands: ["browser.proxy", "browser.proxy.upload.v1"],
platform: "linux",
},
],
);
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
expect(firstRespondCall(respond)[2]?.message).toContain(
"remote upload transfer is pending approval",
);
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
});
it("preserves a configured node failure instead of falling back to the host", async () => {
loadConfigMock.mockReturnValue({
gateway: { nodes: { browser: { mode: "auto", node: "node-1" } } },

View File

@@ -6,6 +6,11 @@ import crypto from "node:crypto";
import { clampTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
BROWSER_PROXY_COMMAND,
BROWSER_PROXY_UPLOAD_COMMAND,
browserProxyUploadUnavailableMessage,
} from "../browser-node-commands.js";
import { isBrowserControlHostUnavailableError } from "../browser-node-fallback.js";
import {
BROWSER_PROXY_ERROR_ENVELOPE,
@@ -14,6 +19,10 @@ import {
type BrowserProxyFile,
type BrowserProxySuccess,
} from "../browser-proxy-envelope.js";
import {
isBrowserProxyUploadRequest,
prepareBrowserProxyUploadRequest,
} from "../browser-proxy-upload.js";
import {
ErrorCodes,
applyBrowserProxyPaths,
@@ -50,7 +59,7 @@ type BrowserRequestParams = {
function isBrowserNode(node: NodeSession) {
const caps = Array.isArray(node.caps) ? node.caps : [];
const commands = Array.isArray(node.commands) ? node.commands : [];
return caps.includes("browser") || commands.includes("browser.proxy");
return caps.includes("browser") || commands.includes(BROWSER_PROXY_COMMAND);
}
function resolveBrowserNode(nodes: NodeSession[], query: string): NodeSession | null {
@@ -151,32 +160,68 @@ export async function handleBrowserGatewayRequest({
}
}
if (nodeTarget && isPersistentBrowserProfileMutation(methodRaw, path)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"browser.request cannot mutate persistent browser profiles over a node proxy",
),
);
return;
}
let preparedUpload: Awaited<ReturnType<typeof prepareBrowserProxyUploadRequest>> | null = null;
let proxyCommand = BROWSER_PROXY_COMMAND;
if (nodeTarget) {
if (isPersistentBrowserProfileMutation(methodRaw, path)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"browser.request cannot mutate persistent browser profiles over a node proxy",
),
if (
isBrowserProxyUploadRequest({ method: methodRaw, path, body }) &&
!nodeTarget.commands?.includes(BROWSER_PROXY_UPLOAD_COMMAND)
) {
const message = browserProxyUploadUnavailableMessage(nodeTarget.declaredCommands);
if (configuredNode) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, message));
return;
}
logger.warn(
`browser node ${nodeTarget.displayName ?? nodeTarget.nodeId} lacks ${BROWSER_PROXY_UPLOAD_COMMAND}; falling back to Gateway host`,
);
nodeTarget = null;
}
}
if (nodeTarget) {
try {
preparedUpload = await prepareBrowserProxyUploadRequest({
method: methodRaw,
path,
body,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, message));
return;
}
if (preparedUpload.upload) {
proxyCommand = BROWSER_PROXY_UPLOAD_COMMAND;
}
}
if (nodeTarget && preparedUpload) {
const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget);
const allowed = isNodeCommandAllowed({
command: "browser.proxy",
command: proxyCommand,
declaredCommands: nodeTarget.commands,
allowlist,
});
if (!allowed.ok) {
const platform = nodeTarget.platform ?? "unknown";
const hint = `node command not allowed: ${allowed.reason} (platform: ${platform}, command: browser.proxy)`;
const hint = `node command not allowed: ${allowed.reason} (platform: ${platform}, command: ${proxyCommand})`;
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, hint, {
details: { reason: allowed.reason, command: "browser.proxy" },
details: { reason: allowed.reason, command: proxyCommand },
}),
);
return;
@@ -186,14 +231,15 @@ export async function handleBrowserGatewayRequest({
method: methodRaw,
path,
query,
body,
body: preparedUpload.body,
upload: preparedUpload.upload,
timeoutMs,
profile: resolveRequestedBrowserProfile({ query, body }),
errorEnvelope: BROWSER_PROXY_ERROR_ENVELOPE,
};
const res = await context.nodeRegistry.invoke({
nodeId: nodeTarget.nodeId,
command: "browser.proxy",
command: proxyCommand,
params: proxyParams,
timeoutMs,
idempotencyKey: crypto.randomUUID(),

View File

@@ -37,6 +37,12 @@ const browserConfigMocks = vi.hoisted(() => ({
})),
}));
const uploadMocks = vi.hoisted(() => ({
stageBrowserProxyUploadRequest: vi.fn(),
discardStagedBrowserProxyUpload: vi.fn(async () => {}),
ensureBrowserProxyUploadCleanup: vi.fn(async () => {}),
}));
vi.mock("../sdk-config.js", () => ({
getRuntimeConfig: configMocks.loadConfig,
getRuntimeConfigSourceSnapshot: () => configMocks.sourceConfig,
@@ -107,6 +113,8 @@ vi.mock("../browser/config.js", () => ({
resolveBrowserConfig: browserConfigMocks.resolveBrowserConfig,
}));
vi.mock("../browser-proxy-upload.js", () => uploadMocks);
vi.mock("../browser/request-policy.js", () => ({
isPersistentBrowserProfileMutation: vi.fn((method: string, path: string) => {
if (method === "POST" && (path === "/profiles/create" || path === "/reset-profile")) {
@@ -158,6 +166,7 @@ let runBrowserProxyCommand: typeof import("./invoke-browser.js").runBrowserProxy
type BrowserDispatchRequest = {
path?: string;
query?: unknown;
body?: unknown;
};
function firstBrowserDispatchRequest(): BrowserDispatchRequest {
@@ -196,6 +205,11 @@ describe("runBrowserProxyCommand", () => {
defaultProfile: "openclaw",
});
controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true);
uploadMocks.stageBrowserProxyUploadRequest
.mockReset()
.mockImplementation(async ({ body }: { body: unknown }) => ({ body }));
uploadMocks.discardStagedBrowserProxyUpload.mockReset().mockResolvedValue(undefined);
uploadMocks.ensureBrowserProxyUploadCleanup.mockReset().mockResolvedValue(undefined);
vi.resetModules();
({ runBrowserProxyCommand } = await import("./invoke-browser.js"));
});
@@ -276,6 +290,230 @@ describe("runBrowserProxyCommand", () => {
expect(dispatcherMocks.dispatch).toHaveBeenCalledTimes(3);
});
it("stages upload envelopes before dispatch and retains successful copies", async () => {
const upload = {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
};
const staged = {
body: { ref: "e12", paths: ["/tmp/openclaw/uploads/.proxy-upload-1/0/report.txt"] },
directory: "/tmp/openclaw/uploads/.proxy-upload-1",
};
uploadMocks.stageBrowserProxyUploadRequest.mockResolvedValueOnce(staged);
dispatcherMocks.dispatch.mockResolvedValueOnce({ status: 200, body: { ok: true } });
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/hooks/file-chooser",
body: { ref: "e12" },
upload,
}),
"browser.proxy.upload.v1",
),
).resolves.toBe(JSON.stringify({ result: { ok: true } }));
expect(uploadMocks.stageBrowserProxyUploadRequest).toHaveBeenCalledWith({
method: "POST",
path: "/hooks/file-chooser",
body: { ref: "e12" },
upload,
signal: expect.any(AbortSignal),
});
expect(firstBrowserDispatchRequest().body).toEqual(staged.body);
expect(uploadMocks.discardStagedBrowserProxyUpload).not.toHaveBeenCalled();
});
it("discards staged copies when the route rejects the upload", async () => {
const staged = {
body: { paths: ["/tmp/openclaw/uploads/.proxy-upload-1/0/report.txt"] },
directory: "/tmp/openclaw/uploads/.proxy-upload-1",
};
uploadMocks.stageBrowserProxyUploadRequest.mockResolvedValueOnce(staged);
dispatcherMocks.dispatch.mockResolvedValueOnce({
status: 400,
body: { error: "upload rejected" },
});
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
}),
"browser.proxy.upload.v1",
),
).rejects.toThrow("400: upload rejected");
expect(uploadMocks.discardStagedBrowserProxyUpload).toHaveBeenCalledWith(staged);
});
it("retains staged copies when dispatch fails after Browser ownership is uncertain", async () => {
const staged = {
body: { paths: ["/tmp/openclaw/uploads/.proxy-upload-1/0/report.txt"] },
directory: "/tmp/openclaw/uploads/.proxy-upload-1",
};
uploadMocks.stageBrowserProxyUploadRequest.mockResolvedValueOnce(staged);
dispatcherMocks.dispatch.mockRejectedValueOnce(new Error("dispatch failed"));
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
}),
"browser.proxy.upload.v1",
),
).rejects.toThrow("dispatch failed");
expect(uploadMocks.discardStagedBrowserProxyUpload).not.toHaveBeenCalled();
});
it("retains staged copies when the timeout wins before dispatch settles", async () => {
const staged = {
body: { paths: ["/tmp/openclaw/uploads/.proxy-upload-1/0/report.txt"] },
directory: "/tmp/openclaw/uploads/.proxy-upload-1",
};
uploadMocks.stageBrowserProxyUploadRequest.mockResolvedValueOnce(staged);
dispatcherMocks.dispatch
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValueOnce({
status: 200,
body: { running: true, cdpReady: true, cdpHttp: true },
});
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
timeoutMs: 5,
}),
"browser.proxy.upload.v1",
),
).rejects.toThrow("browser proxy timed out");
expect(uploadMocks.discardStagedBrowserProxyUpload).not.toHaveBeenCalled();
});
it("rejects upload envelopes sent through the legacy proxy command", async () => {
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
}),
),
).rejects.toThrow("browser.proxy does not accept upload envelopes");
expect(uploadMocks.stageBrowserProxyUploadRequest).not.toHaveBeenCalled();
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
it.each([undefined, null, false, 0, ""])(
"rejects a %j upload envelope sent through the upload proxy command",
async (upload) => {
await expect(
runBrowserProxyCommand(
JSON.stringify({ method: "GET", path: "/snapshot", upload }),
"browser.proxy.upload.v1",
),
).rejects.toThrow("browser.proxy.upload.v1 requires an upload envelope");
expect(uploadMocks.stageBrowserProxyUploadRequest).not.toHaveBeenCalled();
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
},
);
it("does not dispatch after upload staging exhausts the proxy deadline", async () => {
const staged = {
body: { paths: ["/tmp/openclaw/uploads/.proxy-uploads/upload-1/0/report.txt"] },
directory: "/tmp/openclaw/uploads/.proxy-uploads/upload-1",
};
let nowMs = 1_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
uploadMocks.stageBrowserProxyUploadRequest.mockImplementationOnce(async () => {
nowMs += 10;
return staged;
});
try {
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
timeoutMs: 5,
}),
"browser.proxy.upload.v1",
),
).rejects.toThrow("browser proxy timed out");
} finally {
nowSpy.mockRestore();
}
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
expect(uploadMocks.discardStagedBrowserProxyUpload).toHaveBeenCalledWith(staged);
});
it("aborts upload staging when it reaches the proxy deadline", async () => {
uploadMocks.stageBrowserProxyUploadRequest.mockImplementationOnce(
async ({ signal }: { signal?: AbortSignal }) =>
await new Promise((_, reject) => {
signal?.addEventListener(
"abort",
() => {
const reason = signal.reason;
reject(reason instanceof Error ? reason : new Error(String(reason)));
},
{ once: true },
);
}),
);
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/hooks/file-chooser",
body: {},
upload: {
envelope: "browser-upload-v1",
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
},
timeoutMs: 5,
}),
"browser.proxy.upload.v1",
),
).rejects.toThrow("browser proxy timed out");
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
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");

View File

@@ -5,6 +5,7 @@
import fsPromises from "node:fs/promises";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { BROWSER_PROXY_COMMAND, BROWSER_PROXY_UPLOAD_COMMAND } from "../browser-node-commands.js";
import {
assertBrowserProxyFileCountWithinLimit,
assertBrowserProxyFileBytesWithinLimits,
@@ -12,8 +13,14 @@ import {
createBrowserProxyFailure,
type BrowserProxyEnvelope,
type BrowserProxyFile,
type BrowserProxyUploadV1,
visitBrowserProxyFilePaths,
} from "../browser-proxy-envelope.js";
import {
discardStagedBrowserProxyUpload,
ensureBrowserProxyUploadCleanup,
stageBrowserProxyUploadRequest,
} from "../browser-proxy-upload.js";
import { redactCdpUrl } from "../browser/cdp.helpers.js";
import { loadBrowserConfigForRuntimeRefresh } from "../browser/config-refresh-source.js";
import { resolveBrowserConfig } from "../browser/config.js";
@@ -39,6 +46,7 @@ type BrowserProxyParams = {
timeoutMs?: number;
profile?: string;
errorEnvelope?: unknown;
upload?: BrowserProxyUploadV1;
};
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
@@ -147,6 +155,16 @@ function isBrowserProxyTimeoutError(err: unknown): boolean {
return String(err).includes("browser proxy request timed out");
}
function combineBrowserProxySignals(
timeoutSignal: AbortSignal | undefined,
invocationSignal: AbortSignal | undefined,
): AbortSignal | undefined {
if (timeoutSignal && invocationSignal) {
return AbortSignal.any([timeoutSignal, invocationSignal]);
}
return timeoutSignal ?? invocationSignal;
}
function isWsBackedBrowserProxyPath(path: string): boolean {
return (
path === "/act" ||
@@ -226,8 +244,23 @@ function formatBrowserProxyTimeoutMessage(params: {
}
/** Executes a serialized browser.proxy command and returns a serialized result payload. */
export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise<string> {
export async function runBrowserProxyCommand(
paramsJSON?: string | null,
command = BROWSER_PROXY_COMMAND,
invocationSignal?: AbortSignal,
): Promise<string> {
invocationSignal?.throwIfAborted();
void ensureBrowserProxyUploadCleanup();
const params = decodeParams<BrowserProxyParams>(paramsJSON);
if (command === BROWSER_PROXY_COMMAND && params.upload !== undefined) {
throw new Error("INVALID_REQUEST: browser.proxy does not accept upload envelopes");
}
if (command === BROWSER_PROXY_UPLOAD_COMMAND && !params.upload) {
throw new Error("INVALID_REQUEST: browser.proxy.upload.v1 requires an upload envelope");
}
if (command !== BROWSER_PROXY_COMMAND && command !== BROWSER_PROXY_UPLOAD_COMMAND) {
throw new Error(`INVALID_REQUEST: unsupported browser proxy command: ${command}`);
}
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
if (!pathValue) {
throw new Error("INVALID_REQUEST: path required");
@@ -238,11 +271,12 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
}
await ensureBrowserControlService();
invocationSignal?.throwIfAborted();
const cfg = loadBrowserConfigForRuntimeRefresh();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
const path = normalizeBrowserRequestPath(pathValue);
const body = params.body;
let body = params.body;
const requestedProfile =
resolveRequestedBrowserProfile({
query: params.query,
@@ -273,6 +307,7 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
}
const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs);
const deadlineAt = Date.now() + timeoutMs;
const query: Record<string, unknown> = {};
const rawQuery = params.query ?? {};
for (const [key, value] of Object.entries(rawQuery)) {
@@ -286,18 +321,63 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
}
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
let stagedUpload;
try {
stagedUpload = await withTimeout(
(timeoutSignal) =>
stageBrowserProxyUploadRequest({
method,
path,
body,
upload: params.upload,
signal: combineBrowserProxySignals(timeoutSignal, invocationSignal),
}),
timeoutMs,
"browser proxy request",
);
} catch (err) {
if (!isBrowserProxyTimeoutError(err)) {
throw err;
}
throw new Error(
formatBrowserProxyTimeoutMessage({
method,
path,
profile: requestedProfile || resolved.defaultProfile || undefined,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path),
status: null,
}),
{ cause: err },
);
}
body = stagedUpload.body;
const remainingTimeoutMs = deadlineAt - Date.now();
if (remainingTimeoutMs <= 0) {
await discardStagedBrowserProxyUpload(stagedUpload);
throw new Error(
formatBrowserProxyTimeoutMessage({
method,
path,
profile: requestedProfile || resolved.defaultProfile || undefined,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path),
status: null,
}),
);
}
let response;
try {
response = await withTimeout(
(signal) =>
(timeoutSignal) =>
dispatcher.dispatch({
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
path,
query,
body,
signal,
signal: combineBrowserProxySignals(timeoutSignal, invocationSignal),
}),
timeoutMs,
remainingTimeoutMs,
"browser proxy request",
);
} catch (err) {
@@ -321,6 +401,9 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
{ cause: err },
);
}
if (response.status >= 400) {
await discardStagedBrowserProxyUpload(stagedUpload);
}
if (response.status >= 400) {
if (params.errorEnvelope === BROWSER_PROXY_ERROR_ENVELOPE) {
// New callers opt into the closed envelope; older Gateways retain the

View File

@@ -156,6 +156,11 @@ describe("method scope resolution", () => {
expect(
resolveLeastPrivilegeOperatorScopesForMethod("node.invoke", { command: "browser.proxy" }),
).toEqual(["operator.admin"]);
expect(
resolveLeastPrivilegeOperatorScopesForMethod("node.invoke", {
command: "browser.proxy.upload.v1",
}),
).toEqual(["operator.admin"]);
expect(
authorizeOperatorScopesForMethod("node.invoke", ["operator.write"], {
command: "fs.listDir",
@@ -163,7 +168,7 @@ describe("method scope resolution", () => {
).toEqual({ allowed: false, missingScope: "operator.admin" });
expect(
resolveLeastPrivilegeOperatorScopesForMethod("node.invoke", {
command: "browser.proxy",
command: "browser.proxy.upload.v1",
params: { method: "POST", path: "/profiles/create" },
}),
).toEqual(["operator.write"]);

View File

@@ -1,7 +1,10 @@
// Gateway method authorization scope resolver.
// Maps static and plugin-defined gateway methods to operator scopes.
import { normalizeOptionalString as normalizeSessionActionParam } from "@openclaw/normalization-core/string-coerce";
import { isAdminOnlyNodeInvokeCommand } from "../infra/node-commands.js";
import {
isAdminOnlyNodeInvokeCommand,
isBrowserProxyNodeInvokeCommand,
} from "../infra/node-commands.js";
import {
getActivePluginHttpRouteRegistry,
getActivePluginSessionExtensionRegistry,
@@ -195,7 +198,10 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod(
const command = record?.command;
// Invalid persistent-profile mutations must reach the handler's precise fail-closed
// rejection instead of being disguised as an admin-scope failure.
if (command === "browser.proxy" && isForbiddenBrowserProxyMutation(record?.params)) {
if (
isBrowserProxyNodeInvokeCommand(command) &&
isForbiddenBrowserProxyMutation(record?.params)
) {
return [WRITE_SCOPE];
}
return isAdminOnlyNodeInvokeCommand(command) ? [ADMIN_SCOPE] : [WRITE_SCOPE];

View File

@@ -445,10 +445,11 @@ describe("gateway/node-command-policy", () => {
connId: "conn-1",
platform: "linux",
deviceFamily: "Linux",
commands: ["browser.proxy", "system.run"],
commands: ["browser.proxy", "browser.proxy.upload.v1", "system.run"],
});
expect(allowlist.has("browser.proxy")).toBe(true);
expect(allowlist.has("browser.proxy.upload.v1")).toBe(true);
expect(allowlist.has("system.run")).toBe(true);
});

View File

@@ -6,7 +6,7 @@ import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/strin
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
NODE_BROWSER_PROXY_COMMAND,
NODE_BROWSER_PROXY_COMMANDS,
NODE_DEVICE_APPS_COMMAND,
NODE_EXEC_APPROVALS_COMMANDS,
NODE_FILE_COMMANDS,
@@ -79,7 +79,7 @@ const SYSTEM_COMMANDS = [
...NODE_EXEC_APPROVALS_COMMANDS,
...NODE_FILE_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
NODE_BROWSER_PROXY_COMMAND,
...NODE_BROWSER_PROXY_COMMANDS,
NODE_MCP_TOOLS_CALL_COMMAND,
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
];
@@ -87,7 +87,7 @@ const DESKTOP_HOST_COMMANDS = new Set<string>([
...NODE_SYSTEM_RUN_COMMANDS,
...NODE_EXEC_APPROVALS_COMMANDS,
...NODE_FILE_COMMANDS,
NODE_BROWSER_PROXY_COMMAND,
...NODE_BROWSER_PROXY_COMMANDS,
NODE_MCP_TOOLS_CALL_COMMAND,
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
...SCREEN_COMMANDS,

View File

@@ -741,44 +741,47 @@ describe("node.invoke APNs wake path", () => {
vi.useRealTimers();
});
it("rejects browser.proxy for plugin runtime owners without admin scope", async () => {
const nodeRegistry = {
get: vi.fn(() => ({
nodeId: "browser-node",
commands: ["browser.proxy"],
})),
invoke: vi.fn().mockResolvedValue({
ok: true,
payloadJSON: '{"ok":true}',
}),
};
it.each(["browser.proxy", "browser.proxy.upload.v1"])(
"rejects %s for plugin runtime owners without admin scope",
async (command) => {
const nodeRegistry = {
get: vi.fn(() => ({
nodeId: "browser-node",
commands: [command],
})),
invoke: vi.fn().mockResolvedValue({
ok: true,
payloadJSON: '{"ok":true}',
}),
};
const respond = await invokeNode({
nodeRegistry,
client: createOperatorClient({
scopes: ["operator.write"],
pluginRuntimeOwnerId: "third-party",
}),
requestParams: {
nodeId: "browser-node",
command: "browser.proxy",
params: { method: "GET", path: "/profiles" },
},
});
const respond = await invokeNode({
nodeRegistry,
client: createOperatorClient({
scopes: ["operator.write"],
pluginRuntimeOwnerId: "third-party",
}),
requestParams: {
nodeId: "browser-node",
command,
params: { method: "GET", path: "/profiles" },
},
});
const call = firstRespondCall(respond);
expect(call[0]).toBe(false);
expect(call[2]).toMatchObject({
code: "FORBIDDEN",
message: "missing scope: operator.admin",
details: {
code: "MISSING_SCOPE",
missingScope: "operator.admin",
requiredScopes: ["operator.admin"],
},
});
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
});
const call = firstRespondCall(respond);
expect(call[0]).toBe(false);
expect(call[2]).toMatchObject({
code: "FORBIDDEN",
message: "missing scope: operator.admin",
details: {
code: "MISSING_SCOPE",
missingScope: "operator.admin",
requiredScopes: ["operator.admin"],
},
});
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
},
);
it("allows an enabled computer.act command for write-scoped operators", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
@@ -940,40 +943,43 @@ describe("node.invoke APNs wake path", () => {
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
});
it("allows browser.proxy for admin-scoped plugin runtime callers", async () => {
const nodeRegistry = {
get: vi.fn(() => ({
nodeId: "browser-node",
commands: ["browser.proxy"],
})),
invoke: vi.fn().mockResolvedValue({
ok: true,
payloadJSON: '{"ok":true}',
}),
};
it.each(["browser.proxy", "browser.proxy.upload.v1"])(
"allows %s for admin-scoped plugin runtime callers",
async (command) => {
const nodeRegistry = {
get: vi.fn(() => ({
nodeId: "browser-node",
commands: [command],
})),
invoke: vi.fn().mockResolvedValue({
ok: true,
payloadJSON: '{"ok":true}',
}),
};
const respond = await invokeNode({
nodeRegistry,
client: createOperatorClient({
scopes: ["operator.admin"],
pluginRuntimeOwnerId: "google-meet",
}),
requestParams: {
const respond = await invokeNode({
nodeRegistry,
client: createOperatorClient({
scopes: ["operator.admin"],
pluginRuntimeOwnerId: "google-meet",
}),
requestParams: {
nodeId: "browser-node",
command,
params: { method: "GET", path: "/profiles" },
},
});
const call = firstRespondCall(respond);
expect(call[0]).toBe(true);
expect(nodeRegistry.invoke).toHaveBeenCalledTimes(1);
expectRecordFields(mockArg(nodeRegistry.invoke, 0, 0), "node invoke payload", {
nodeId: "browser-node",
command: "browser.proxy",
command,
params: { method: "GET", path: "/profiles" },
},
});
const call = firstRespondCall(respond);
expect(call[0]).toBe(true);
expect(nodeRegistry.invoke).toHaveBeenCalledTimes(1);
expectRecordFields(mockArg(nodeRegistry.invoke, 0, 0), "node invoke payload", {
nodeId: "browser-node",
command: "browser.proxy",
params: { method: "GET", path: "/profiles" },
});
});
});
},
);
it("keeps the existing not-connected response when wake path is unavailable", async () => {
mocks.loadApnsRegistration.mockResolvedValue(null);

View File

@@ -6,7 +6,10 @@ import {
missingScopeErrorShape,
validateNodeInvokeParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { isAdminOnlyNodeInvokeCommand } from "../../infra/node-commands.js";
import {
isAdminOnlyNodeInvokeCommand,
isBrowserProxyNodeInvokeCommand,
} from "../../infra/node-commands.js";
import { captureNodePairingGeneration } from "../../infra/node-pairing-state.js";
import { isForbiddenBrowserProxyMutation } from "../node-browser-proxy-policy.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
@@ -168,13 +171,13 @@ export const nodeInvokeHandlers: GatewayRequestHandlers = {
if (nodeInvokePolicy.rejectClaudeAgentRun(command, respond)) {
return;
}
if (command === "browser.proxy" && isForbiddenBrowserProxyMutation(p.params)) {
if (isBrowserProxyNodeInvokeCommand(command) && isForbiddenBrowserProxyMutation(p.params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"node.invoke cannot mutate persistent browser profiles via browser.proxy",
`node.invoke cannot mutate persistent browser profiles via ${command}`,
{ details: { command } },
),
);

View File

@@ -558,38 +558,41 @@ describe("node.invoke approval bypass", () => {
}
});
test("rejects browser.proxy persistent profile mutations before forwarding", async () => {
let sawInvoke = false;
const node = await connectLinuxNode(
() => {
sawInvoke = true;
},
undefined,
["browser.proxy"],
);
const ws = await connectOperator(["operator.write"]);
try {
const nodeId = await getConnectedNodeIdForTest(ws);
const res = await rpcReq(ws, "node.invoke", {
nodeId,
command: "browser.proxy",
params: {
method: "POST",
path: "/profiles/create",
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
test.each(["browser.proxy", "browser.proxy.upload.v1"])(
"rejects %s persistent profile mutations before forwarding",
async (command) => {
let sawInvoke = false;
const node = await connectLinuxNode(
() => {
sawInvoke = true;
},
idempotencyKey: crypto.randomUUID(),
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain(
"node.invoke cannot mutate persistent browser profiles via browser.proxy",
undefined,
[command],
);
await expectNoForwardedInvoke(() => sawInvoke);
} finally {
ws.close();
node.stop();
}
});
const ws = await connectOperator(["operator.write"]);
try {
const nodeId = await getConnectedNodeIdForTest(ws);
const res = await rpcReq(ws, "node.invoke", {
nodeId,
command,
params: {
method: "POST",
path: "/profiles/create",
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
},
idempotencyKey: crypto.randomUUID(),
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain(
`node.invoke cannot mutate persistent browser profiles via ${command}`,
);
await expectNoForwardedInvoke(() => sawInvoke);
} finally {
ws.close();
node.stop();
}
},
);
test("requires admin scope for direct browser.proxy node.invoke", async () => {
let sawInvoke = false;

View File

@@ -9,7 +9,12 @@ export const NODE_SYSTEM_NOTIFY_COMMAND = "system.notify";
export const NODE_FS_LIST_DIR_COMMAND = "fs.listDir";
export const NODE_TERMINAL_UPLOAD_COMMAND = "terminal.upload";
export const NODE_FILE_COMMANDS = [NODE_FS_LIST_DIR_COMMAND, NODE_TERMINAL_UPLOAD_COMMAND];
export const NODE_BROWSER_PROXY_COMMAND = "browser.proxy";
const NODE_BROWSER_PROXY_COMMAND = "browser.proxy";
const NODE_BROWSER_PROXY_UPLOAD_COMMAND = "browser.proxy.upload.v1";
export const NODE_BROWSER_PROXY_COMMANDS = [
NODE_BROWSER_PROXY_COMMAND,
NODE_BROWSER_PROXY_UPLOAD_COMMAND,
] as const;
export const NODE_MCP_TOOLS_CALL_COMMAND = "mcp.tools.call.v1";
export const NODE_AGENT_CLI_CLAUDE_RUN_COMMAND = "agent.cli.claude.run.v1";
export const NODE_DEVICE_APPS_COMMAND = "device.apps";
@@ -25,7 +30,7 @@ export const NODE_EXEC_APPROVALS_COMMANDS = [
// Direct node.invoke and pairing approval share this admin-only subset.
const NODE_ADMIN_ONLY_INVOKE_COMMANDS = [
NODE_BROWSER_PROXY_COMMAND,
...NODE_BROWSER_PROXY_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_TERMINAL_UPLOAD_COMMAND,
] as const;
@@ -37,5 +42,13 @@ export function isAdminOnlyNodeInvokeCommand(command: unknown): boolean {
return typeof command === "string" && NODE_ADMIN_ONLY_INVOKE_COMMAND_SET.has(command);
}
/** Returns true for every versioned Browser node proxy command. */
export function isBrowserProxyNodeInvokeCommand(command: unknown): boolean {
return (
typeof command === "string" &&
(NODE_BROWSER_PROXY_COMMANDS as readonly string[]).includes(command)
);
}
export const NODE_MCP_TOOL_CALL_TIMEOUT_MS = 120_000;
export const NODE_MCP_TOOL_CALL_GATEWAY_TIMEOUT_MS = NODE_MCP_TOOL_CALL_TIMEOUT_MS + 5_000;

View File

@@ -1,7 +1,7 @@
// Covers scope requirements for node pairing approvals.
import { describe, expect, it } from "vitest";
import {
NODE_BROWSER_PROXY_COMMAND,
NODE_BROWSER_PROXY_COMMANDS,
NODE_EXEC_APPROVALS_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_SYSTEM_RUN_COMMANDS,
@@ -11,7 +11,7 @@ import {
import { resolveNodePairApprovalScopes } from "./node-pairing-authz.js";
const ADMIN_ONLY_INVOKE_COMMANDS = [
NODE_BROWSER_PROXY_COMMAND,
...NODE_BROWSER_PROXY_COMMANDS,
NODE_FS_LIST_DIR_COMMAND,
NODE_TERMINAL_UPLOAD_COMMAND,
] as const;

View File

@@ -234,13 +234,20 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set<string> {
"system.run.prepare",
"system.which",
"browser.proxy",
"browser.proxy.upload.v1",
"screen.snapshot",
],
},
{
platform: "linux",
deviceFamily: "Linux",
approvedCommands: ["system.run", "system.run.prepare", "system.which", "browser.proxy"],
approvedCommands: [
"system.run",
"system.run.prepare",
"system.which",
"browser.proxy",
"browser.proxy.upload.v1",
],
},
{
platform: "windows",
@@ -250,6 +257,7 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set<string> {
"system.run.prepare",
"system.which",
"browser.proxy",
"browser.proxy.upload.v1",
"screen.snapshot",
],
},