From 151c549494b20f02f6f098e51ecf92d8a629ecdc Mon Sep 17 00:00:00 2001 From: synth Date: Wed, 29 Jul 2026 08:31:46 -0400 Subject: [PATCH] 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 --- docs/cli/nodes.md | 2 +- docs/gateway/operator-scopes.md | 13 +- docs/gateway/pairing.md | 4 +- docs/gateway/protocol.md | 10 +- docs/nodes/index.md | 2 +- extensions/browser/index.test.ts | 29 +- extensions/browser/plugin-registration.ts | 35 +- extensions/browser/register.runtime.ts | 1 + .../browser/src/browser-node-commands.ts | 10 + .../browser/src/browser-node-proxy.test.ts | 139 ++++- extensions/browser/src/browser-node-proxy.ts | 67 +- .../browser/src/browser-proxy-envelope.ts | 19 +- .../browser/src/browser-proxy-upload.test.ts | 477 ++++++++++++++ .../browser/src/browser-proxy-upload.ts | 583 ++++++++++++++++++ extensions/browser/src/browser-tool.test.ts | 32 +- extensions/browser/src/browser-tool.ts | 22 +- .../browser-request.profile-from-body.test.ts | 187 +++++- .../browser/src/gateway/browser-request.ts | 74 ++- .../src/node-host/invoke-browser.test.ts | 238 +++++++ .../browser/src/node-host/invoke-browser.ts | 93 ++- src/gateway/method-scopes.test.ts | 7 +- src/gateway/method-scopes.ts | 10 +- src/gateway/node-command-policy.test.ts | 3 +- src/gateway/node-command-policy.ts | 6 +- .../server-methods/nodes.invoke-wake.test.ts | 140 +++-- src/gateway/server-methods/nodes.invoke.ts | 9 +- ...server.node-invoke-approval-bypass.test.ts | 63 +- src/infra/node-commands.ts | 17 +- src/infra/node-pairing-authz.test.ts | 4 +- src/security/audit-extra.sync.ts | 10 +- 30 files changed, 2117 insertions(+), 189 deletions(-) create mode 100644 extensions/browser/src/browser-node-commands.ts create mode 100644 extensions/browser/src/browser-proxy-upload.test.ts create mode 100644 extensions/browser/src/browser-proxy-upload.ts diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index fc9d751fb557..c9a74c848933 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -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 diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index 2026fd81c1b6..209427653587 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -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; diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 7a496350b3a9..4e22a482ce0c 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -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` Node pairing approval records the trusted capability surface. It does **not** pin the live node command surface per node. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index e99ad41c602e..15c26dd42d58 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -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) diff --git a/docs/nodes/index.md b/docs/nodes/index.md index ec346aa09047..d527dcb327c1 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -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. diff --git a/extensions/browser/index.test.ts b/extensions/browser/index.test.ts index 1c1a7d14f182..7f3419df10e3 100644 --- a/extensions/browser/index.test.ts +++ b/extensions/browser/index.test.ts @@ -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(); diff --git a/extensions/browser/plugin-registration.ts b/extensions/browser/plugin-registration.ts index 38c2b7285579..3b1bfb431dfa 100644 --- a/extensions/browser/plugin-registration.ts +++ b/extensions/browser/plugin-registration.ts @@ -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. */ diff --git a/extensions/browser/register.runtime.ts b/extensions/browser/register.runtime.ts index dda88713982e..e33d843dc77c 100644 --- a/extensions/browser/register.runtime.ts +++ b/extensions/browser/register.runtime.ts @@ -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"; diff --git a/extensions/browser/src/browser-node-commands.ts b/extensions/browser/src/browser-node-commands.ts new file mode 100644 index 000000000000..a938228e9d86 --- /dev/null +++ b/extensions/browser/src/browser-node-commands.ts @@ -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"; +} diff --git a/extensions/browser/src/browser-node-proxy.test.ts b/extensions/browser/src/browser-node-proxy.test.ts index 64a3b044a2fc..bfdeeaea1c96 100644 --- a/extensions/browser/src/browser-node-proxy.test.ts +++ b/extensions/browser/src/browser-node-proxy.test.ts @@ -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>(), })); +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()); 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(); @@ -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(); diff --git a/extensions/browser/src/browser-node-proxy.ts b/extensions/browser/src/browser-node-proxy.ts index 7e1f90d46098..1ba0811dd770 100644 --- a/extensions/browser/src/browser-node-proxy.ts +++ b/extensions/browser/src/browser-node-proxy.ts @@ -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; @@ -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[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); } diff --git a/extensions/browser/src/browser-proxy-envelope.ts b/extensions/browser/src/browser-proxy-envelope.ts index ef30e34d76f7..717c5bb54380 100644 --- a/extensions/browser/src/browser-proxy-envelope.ts +++ b/extensions/browser/src/browser-proxy-envelope.ts @@ -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, diff --git a/extensions/browser/src/browser-proxy-upload.test.ts b/extensions/browser/src/browser-proxy-upload.test.ts new file mode 100644 index 000000000000..db9f1ea5d258 --- /dev/null +++ b/extensions/browser/src/browser-proxy-upload.test.ts @@ -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 { + 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> + > => 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 }); + }); +}); diff --git a/extensions/browser/src/browser-proxy-upload.ts b/extensions/browser/src/browser-proxy-upload.ts new file mode 100644 index 000000000000..590ae3499c4f --- /dev/null +++ b/extensions/browser/src/browser-proxy-upload.ts @@ -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>(); +const recoveryPromises = new Map>(); +const recoveryRetryTimers = new Map>(); +const stagingLocks = new Map>(); + +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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readUploadPaths(body: Record): 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 { + 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 { + 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 { + 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 { + 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 { + 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 => { + 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 { + 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 { + 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 { + 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, signal?: AbortSignal): Promise { + if (!signal) { + await previous; + return; + } + signal.throwIfAborted(); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, 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( + uploadDir: string, + task: () => Promise, + signal?: AbortSignal, +): Promise { + const previous = stagingLocks.get(uploadDir) ?? Promise.resolve(); + let release = () => {}; + const current = new Promise((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 { + 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 { + if (staged.directory) { + await removeStagedUpload(staged.directory); + } +} diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index c3d33dd44aa8..0f31a19e222d 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -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. */ diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index 904ef9afd559..ab13f95c5467 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -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) { 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; diff --git a/extensions/browser/src/gateway/browser-request.profile-from-body.test.ts b/extensions/browser/src/gateway/browser-request.profile-from-body.test.ts index ee95c48ead29..b9511a3b0de8 100644 --- a/extensions/browser/src/gateway/browser-request.profile-from-body.test.ts +++ b/extensions/browser/src/gateway/browser-request.profile-from-body.test.ts @@ -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" } } }, diff --git a/extensions/browser/src/gateway/browser-request.ts b/extensions/browser/src/gateway/browser-request.ts index a1c14313ddf9..c4549853b091 100644 --- a/extensions/browser/src/gateway/browser-request.ts +++ b/extensions/browser/src/gateway/browser-request.ts @@ -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> | 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(), diff --git a/extensions/browser/src/node-host/invoke-browser.test.ts b/extensions/browser/src/node-host/invoke-browser.test.ts index b28d4e0224b6..019dcfb3eff7 100644 --- a/extensions/browser/src/node-host/invoke-browser.test.ts +++ b/extensions/browser/src/node-host/invoke-browser.test.ts @@ -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"); diff --git a/extensions/browser/src/node-host/invoke-browser.ts b/extensions/browser/src/node-host/invoke-browser.ts index aec83075fe1d..a06be369504d 100644 --- a/extensions/browser/src/node-host/invoke-browser.ts +++ b/extensions/browser/src/node-host/invoke-browser.ts @@ -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 { +export async function runBrowserProxyCommand( + paramsJSON?: string | null, + command = BROWSER_PROXY_COMMAND, + invocationSignal?: AbortSignal, +): Promise { + invocationSignal?.throwIfAborted(); + void ensureBrowserProxyUploadCleanup(); const params = decodeParams(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 = {}; 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 diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index ca417f78d1fc..43ea93b00229 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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"]); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index e35b30ca37ae..7ed06eca68d0 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -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]; diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index b6b3fdf7b8a1..848eb98d7c52 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -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); }); diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 4ef96943ef68..9784db6e805f 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -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([ ...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, diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 02e695fcc8f3..38d49977eff5 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -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); diff --git a/src/gateway/server-methods/nodes.invoke.ts b/src/gateway/server-methods/nodes.invoke.ts index bf559562994f..3dd02dd599f7 100644 --- a/src/gateway/server-methods/nodes.invoke.ts +++ b/src/gateway/server-methods/nodes.invoke.ts @@ -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 } }, ), ); diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index e6c66ed4ade0..3fb8bebd580b 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -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; diff --git a/src/infra/node-commands.ts b/src/infra/node-commands.ts index 98cf5f2b59ac..e4cc073a618e 100644 --- a/src/infra/node-commands.ts +++ b/src/infra/node-commands.ts @@ -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; diff --git a/src/infra/node-pairing-authz.test.ts b/src/infra/node-pairing-authz.test.ts index 4889720204f1..d0858fac9a66 100644 --- a/src/infra/node-pairing-authz.test.ts +++ b/src/infra/node-pairing-authz.test.ts @@ -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; diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 2cb31c95b473..77406ceb005a 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -234,13 +234,20 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set { "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 { "system.run.prepare", "system.which", "browser.proxy", + "browser.proxy.upload.v1", "screen.snapshot", ], },