mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
fix(browser): uploads fail when the browser runs on a remote node (#115291)
* fix(browser): resolve upload paths on the owning browser node, not the Gateway When a browser session is proxied to a remote node, the upload action previously ran resolveExistingUploadPaths on the Gateway, pinning paths to a filesystem the node cannot see and rejecting node-local files. The node-side /hooks/file-chooser route already re-resolves paths against its own filesystem, so skip Gateway-local resolution whenever the request is proxied and forward the requested paths as-is. Fixes openclaw/openclaw#115251 * fix(browser): transfer uploads to remote browser nodes * fix(browser): normalize upload abort errors --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -91,8 +91,8 @@ Notes:
|
||||
- commandless request: `operator.pairing`
|
||||
- ordinary command request: `operator.pairing` + `operator.write`
|
||||
- admin-sensitive request containing `system.run`, `system.run.prepare`,
|
||||
`system.which`, `browser.proxy`, `fs.listDir`, or
|
||||
`system.execApprovals.get/set`: `operator.pairing` + `operator.admin`
|
||||
`system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `fs.listDir`,
|
||||
or `system.execApprovals.get/set`: `operator.pairing` + `operator.admin`
|
||||
|
||||
<Warning>
|
||||
Node pairing approval records the trusted capability surface. It does **not** pin the live node command surface per node.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
10
extensions/browser/src/browser-node-commands.ts
Normal file
10
extensions/browser/src/browser-node-commands.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const BROWSER_PROXY_COMMAND = "browser.proxy";
|
||||
export const BROWSER_PROXY_UPLOAD_COMMAND = "browser.proxy.upload.v1";
|
||||
|
||||
export function browserProxyUploadUnavailableMessage(
|
||||
pendingDeclaredCommands: readonly string[] | undefined,
|
||||
): string {
|
||||
return pendingDeclaredCommands?.includes(BROWSER_PROXY_UPLOAD_COMMAND)
|
||||
? "browser node remote upload transfer is pending approval; approve the node's pending command update before retrying"
|
||||
: "browser node does not support remote upload transfer; update the node or approve its pending command update before retrying";
|
||||
}
|
||||
@@ -13,6 +13,8 @@ type BrowserNodeRequest = {
|
||||
timeoutMs: number;
|
||||
profile?: string;
|
||||
errorEnvelope: string;
|
||||
body?: unknown;
|
||||
upload?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,13 +36,28 @@ const runtimeMocks = vi.hoisted(() => ({
|
||||
fetchBrowserJson: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
|
||||
}));
|
||||
|
||||
const uploadMocks = vi.hoisted(() => ({
|
||||
isBrowserProxyUploadRequest: vi.fn(
|
||||
(params: { method: string; path: string; body: unknown }) =>
|
||||
params.method === "POST" &&
|
||||
params.path === "/hooks/file-chooser" &&
|
||||
Array.isArray((params.body as { paths?: unknown } | undefined)?.paths) &&
|
||||
((params.body as { paths: unknown[] }).paths.length ?? 0) > 0,
|
||||
),
|
||||
prepareBrowserProxyUploadRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./browser-tool.runtime.js", () => runtimeMocks);
|
||||
vi.mock("./browser-proxy-upload.js", () => uploadMocks);
|
||||
|
||||
import { createBrowserNodeProxyRequest } from "./browser-node-proxy.js";
|
||||
|
||||
function createSessionProxy() {
|
||||
return createBrowserNodeProxyRequest({
|
||||
nodeTarget: { nodeId: "node-1" },
|
||||
nodeTarget: {
|
||||
nodeId: "node-1",
|
||||
commands: ["browser.proxy", "browser.proxy.upload.v1"],
|
||||
},
|
||||
allowAutomaticHostFallback: false,
|
||||
});
|
||||
}
|
||||
@@ -61,6 +78,10 @@ beforeEach(() => {
|
||||
runtimeMocks.persistBrowserProxyFiles.mockResolvedValue(new Map<string, string>());
|
||||
runtimeMocks.applyBrowserProxyPaths.mockReset();
|
||||
runtimeMocks.fetchBrowserJson.mockReset();
|
||||
uploadMocks.isBrowserProxyUploadRequest.mockClear();
|
||||
uploadMocks.prepareBrowserProxyUploadRequest
|
||||
.mockReset()
|
||||
.mockImplementation(async ({ body }: { body: unknown }) => ({ body }));
|
||||
});
|
||||
|
||||
describe("Browser node proxy nested watchdogs", () => {
|
||||
@@ -140,6 +161,114 @@ describe("Browser node proxy nested watchdogs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends Gateway-owned upload bytes without node-facing source paths", async () => {
|
||||
const originalBody = {
|
||||
paths: ["/tmp/openclaw/uploads/report.txt"],
|
||||
ref: "e12",
|
||||
};
|
||||
const upload = {
|
||||
envelope: "browser-upload-v1",
|
||||
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
|
||||
};
|
||||
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
|
||||
body: { ref: "e12" },
|
||||
upload,
|
||||
});
|
||||
|
||||
await createSessionProxy()({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: originalBody,
|
||||
});
|
||||
|
||||
expect(readGatewayCall().node.params).toMatchObject({
|
||||
body: { ref: "e12" },
|
||||
upload,
|
||||
});
|
||||
expect(readGatewayCall().node.command).toBe("browser.proxy.upload.v1");
|
||||
expect(readGatewayCall().node.params.body).not.toHaveProperty("paths");
|
||||
});
|
||||
|
||||
it("uses the original Gateway paths when an auto-selected old node lacks upload support", async () => {
|
||||
const originalBody = {
|
||||
paths: ["/tmp/openclaw/uploads/report.txt"],
|
||||
ref: "e12",
|
||||
};
|
||||
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
|
||||
body: { ref: "e12" },
|
||||
upload: {
|
||||
envelope: "browser-upload-v1",
|
||||
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
|
||||
},
|
||||
});
|
||||
runtimeMocks.fetchBrowserJson.mockResolvedValueOnce({ ok: true });
|
||||
const proxy = createBrowserNodeProxyRequest({
|
||||
nodeTarget: { nodeId: "node-1", commands: ["browser.proxy"] },
|
||||
allowAutomaticHostFallback: true,
|
||||
});
|
||||
|
||||
await proxy({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: originalBody,
|
||||
});
|
||||
|
||||
expect(runtimeMocks.fetchBrowserJson).toHaveBeenCalledWith(
|
||||
"/hooks/file-chooser",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(originalBody),
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an explicit old node upload before node dispatch", async () => {
|
||||
uploadMocks.prepareBrowserProxyUploadRequest.mockResolvedValueOnce({
|
||||
body: { ref: "e12" },
|
||||
upload: {
|
||||
envelope: "browser-upload-v1",
|
||||
files: [{ name: "report.txt", contentBase64: "aGVsbG8=" }],
|
||||
},
|
||||
});
|
||||
const proxy = createBrowserNodeProxyRequest({
|
||||
nodeTarget: { nodeId: "node-1", commands: ["browser.proxy"] },
|
||||
allowAutomaticHostFallback: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
proxy({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: ["/tmp/openclaw/uploads/report.txt"], ref: "e12" },
|
||||
}),
|
||||
).rejects.toThrow("browser node does not support remote upload transfer");
|
||||
expect(runtimeMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains when remote upload transfer is awaiting node command approval", async () => {
|
||||
const proxy = createBrowserNodeProxyRequest({
|
||||
nodeTarget: {
|
||||
nodeId: "node-1",
|
||||
commands: ["browser.proxy"],
|
||||
pendingDeclaredCommands: ["browser.proxy", "browser.proxy.upload.v1"],
|
||||
},
|
||||
allowAutomaticHostFallback: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
proxy({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: ["/tmp/openclaw/uploads/report.txt"], ref: "e12" },
|
||||
}),
|
||||
).rejects.toThrow("remote upload transfer is pending approval");
|
||||
expect(runtimeMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
expect(uploadMocks.prepareBrowserProxyUploadRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps default action, node, and Gateway watchdogs strictly nested", async () => {
|
||||
await createSessionProxy()({ method: "GET", path: "/snapshot" });
|
||||
|
||||
@@ -195,7 +324,9 @@ describe("Browser node proxy nested watchdogs", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10);
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
expect(completed.size).toBe(0);
|
||||
expect(runtimeMocks.persistBrowserProxyFiles).not.toHaveBeenCalled();
|
||||
const invocationIds = new Set<string>();
|
||||
@@ -283,7 +414,9 @@ describe("Browser node proxy nested watchdogs", () => {
|
||||
const abortError = new Error("session-3 cancelled");
|
||||
|
||||
try {
|
||||
expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10);
|
||||
await vi.waitFor(() => {
|
||||
expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
cancelledSession.controller.abort(abortError);
|
||||
await expect(cancelledRun).rejects.toBe(abortError);
|
||||
expect(runtimeMocks.persistBrowserProxyFiles).not.toHaveBeenCalled();
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
resolveTimerTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
BROWSER_PROXY_COMMAND,
|
||||
BROWSER_PROXY_UPLOAD_COMMAND,
|
||||
browserProxyUploadUnavailableMessage,
|
||||
} from "./browser-node-commands.js";
|
||||
import { isBrowserControlHostUnavailableError } from "./browser-node-fallback.js";
|
||||
import {
|
||||
BROWSER_PROXY_ERROR_ENVELOPE,
|
||||
@@ -12,6 +17,10 @@ import {
|
||||
type BrowserProxyEnvelope,
|
||||
type BrowserProxySuccess,
|
||||
} from "./browser-proxy-envelope.js";
|
||||
import {
|
||||
isBrowserProxyUploadRequest,
|
||||
prepareBrowserProxyUploadRequest,
|
||||
} from "./browser-proxy-upload.js";
|
||||
import {
|
||||
applyBrowserProxyPaths,
|
||||
callGatewayTool,
|
||||
@@ -24,10 +33,10 @@ const logger = createSubsystemLogger("browser");
|
||||
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
|
||||
const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000;
|
||||
|
||||
class BrowserNodeControlHostUnavailableError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super("auto-selected browser node control host unavailable", { cause });
|
||||
this.name = "BrowserNodeControlHostUnavailableError";
|
||||
class BrowserNodeSafeFallbackError extends Error {
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "BrowserNodeSafeFallbackError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +70,9 @@ function unwrapBrowserProxyPayload(
|
||||
|
||||
async function callBrowserProxy(params: {
|
||||
nodeId: string;
|
||||
markControlHostUnavailable: boolean;
|
||||
declaredCommands: readonly string[];
|
||||
pendingDeclaredCommands: readonly string[];
|
||||
allowAutomaticHostFallback: boolean;
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
@@ -82,6 +93,21 @@ async function callBrowserProxy(params: {
|
||||
const gatewayTimeoutMs =
|
||||
addTimerTimeoutGraceMs(nodeInvokeTimeoutMs, BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS) ??
|
||||
nodeInvokeTimeoutMs;
|
||||
if (
|
||||
isBrowserProxyUploadRequest(params) &&
|
||||
!params.declaredCommands.includes(BROWSER_PROXY_UPLOAD_COMMAND)
|
||||
) {
|
||||
throw new BrowserNodeSafeFallbackError(
|
||||
browserProxyUploadUnavailableMessage(params.pendingDeclaredCommands),
|
||||
);
|
||||
}
|
||||
const preparedUpload = await prepareBrowserProxyUploadRequest({
|
||||
method: params.method,
|
||||
path: params.path,
|
||||
body: params.body,
|
||||
signal: params.signal,
|
||||
});
|
||||
const command = preparedUpload.upload ? BROWSER_PROXY_UPLOAD_COMMAND : BROWSER_PROXY_COMMAND;
|
||||
let payload: { payload?: unknown; payloadJSON?: unknown } | null;
|
||||
try {
|
||||
payload = await callGatewayTool<{ payload?: unknown; payloadJSON?: unknown }>(
|
||||
@@ -89,7 +115,7 @@ async function callBrowserProxy(params: {
|
||||
{ timeoutMs: gatewayTimeoutMs },
|
||||
{
|
||||
nodeId: params.nodeId,
|
||||
command: "browser.proxy",
|
||||
command,
|
||||
// Keep the browser action, node watchdog, and Gateway RPC on distinct
|
||||
// budgets so a detailed node timeout can cross both outer boundaries.
|
||||
timeoutMs: nodeInvokeTimeoutMs,
|
||||
@@ -97,7 +123,8 @@ async function callBrowserProxy(params: {
|
||||
method: params.method,
|
||||
path: params.path,
|
||||
query: params.query,
|
||||
body: params.body,
|
||||
body: preparedUpload.body,
|
||||
upload: preparedUpload.upload,
|
||||
timeoutMs: proxyTimeoutMs,
|
||||
profile: params.profile,
|
||||
errorEnvelope: BROWSER_PROXY_ERROR_ENVELOPE,
|
||||
@@ -110,8 +137,8 @@ async function callBrowserProxy(params: {
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (params.markControlHostUnavailable && isBrowserControlHostUnavailableError(error)) {
|
||||
throw new BrowserNodeControlHostUnavailableError(error);
|
||||
if (params.allowAutomaticHostFallback && isBrowserControlHostUnavailableError(error)) {
|
||||
throw new BrowserNodeSafeFallbackError("browser node control host unavailable", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -146,7 +173,12 @@ async function callLocalBrowserControl(params: Parameters<BrowserProxyRequest>[0
|
||||
}
|
||||
|
||||
export function createBrowserNodeProxyRequest(params: {
|
||||
nodeTarget: { nodeId: string; label?: string };
|
||||
nodeTarget: {
|
||||
nodeId: string;
|
||||
label?: string;
|
||||
commands?: string[];
|
||||
pendingDeclaredCommands?: string[];
|
||||
};
|
||||
allowAutomaticHostFallback: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): BrowserProxyRequest {
|
||||
@@ -164,24 +196,23 @@ export function createBrowserNodeProxyRequest(params: {
|
||||
try {
|
||||
const proxy = await callBrowserProxy({
|
||||
nodeId: params.nodeTarget.nodeId,
|
||||
markControlHostUnavailable: params.allowAutomaticHostFallback,
|
||||
declaredCommands: params.nodeTarget.commands ?? [],
|
||||
pendingDeclaredCommands: params.nodeTarget.pendingDeclaredCommands ?? [],
|
||||
allowAutomaticHostFallback: params.allowAutomaticHostFallback,
|
||||
...requestWithSignal,
|
||||
});
|
||||
const mapping = await persistBrowserProxyFiles(proxy.files);
|
||||
applyBrowserProxyPaths(proxy.result, mapping);
|
||||
return proxy.result;
|
||||
} catch (error) {
|
||||
if (
|
||||
!params.allowAutomaticHostFallback ||
|
||||
!(error instanceof BrowserNodeControlHostUnavailableError)
|
||||
) {
|
||||
if (!params.allowAutomaticHostFallback || !(error instanceof BrowserNodeSafeFallbackError)) {
|
||||
throw error;
|
||||
}
|
||||
// This exact node-host failure occurs before any browser action. Retrying
|
||||
// other failures could duplicate a mutating operation.
|
||||
// These failures are detected before route dispatch. Retrying any later
|
||||
// failure could duplicate a mutating browser action.
|
||||
hostFallbackActive = true;
|
||||
logger.warn(
|
||||
`browser node ${params.nodeTarget.label ?? params.nodeTarget.nodeId} control host unavailable; falling back to Gateway host`,
|
||||
`browser node ${params.nodeTarget.label ?? params.nodeTarget.nodeId} unavailable before dispatch (${error.message}); falling back to Gateway host`,
|
||||
);
|
||||
return await callLocalBrowserControl(requestWithSignal);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
477
extensions/browser/src/browser-proxy-upload.test.ts
Normal file
477
extensions/browser/src/browser-proxy-upload.test.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
type BrowserProxyUploadV1,
|
||||
} from "./browser-proxy-envelope.js";
|
||||
import {
|
||||
discardStagedBrowserProxyUpload,
|
||||
ensureBrowserProxyUploadCleanup,
|
||||
prepareBrowserProxyUploadRequest,
|
||||
stageBrowserProxyUploadRequest,
|
||||
} from "./browser-proxy-upload.js";
|
||||
import { resolveExistingUploadPaths } from "./browser/paths.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function createTempRoot(label: string): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), label));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("browser proxy upload transport", () => {
|
||||
it("reads Gateway-owned files into a versioned envelope and omits node-facing paths", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-gateway-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
const inboundMediaDir = path.join(root, "media", "inbound");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
const sourcePath = path.join(uploadDir, "report.txt");
|
||||
await fs.writeFile(sourcePath, "gateway bytes", "utf8");
|
||||
|
||||
const prepared = await prepareBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: [sourcePath], ref: "e12" },
|
||||
uploadDir,
|
||||
inboundMediaDir,
|
||||
});
|
||||
|
||||
expect(prepared.body).toEqual({ ref: "e12" });
|
||||
expect(prepared.upload).toEqual({
|
||||
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
files: [
|
||||
{
|
||||
name: "report.txt",
|
||||
contentBase64: Buffer.from("gateway bytes").toString("base64"),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves zero-byte files", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-empty-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
const sourcePath = path.join(uploadDir, "empty.txt");
|
||||
await fs.writeFile(sourcePath, "");
|
||||
|
||||
const prepared = await prepareBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: [sourcePath] },
|
||||
uploadDir,
|
||||
inboundMediaDir: path.join(root, "inbound"),
|
||||
});
|
||||
|
||||
expect(prepared.upload?.files).toEqual([{ name: "empty.txt", contentBase64: "" }]);
|
||||
});
|
||||
|
||||
it("transfers Gateway inbound media bytes instead of resolving the URI on the node", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-inbound-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
const inboundMediaDir = path.join(root, "media", "inbound");
|
||||
await fs.mkdir(inboundMediaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(inboundMediaDir, "report.txt"), "gateway inbound bytes", "utf8");
|
||||
|
||||
const prepared = await prepareBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: ["media://inbound/report.txt"] },
|
||||
uploadDir,
|
||||
inboundMediaDir,
|
||||
});
|
||||
|
||||
expect(prepared.body).toEqual({});
|
||||
expect(prepared.upload?.files).toEqual([
|
||||
{
|
||||
name: "report.txt",
|
||||
contentBase64: Buffer.from("gateway inbound bytes").toString("base64"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries a prepared Gateway file through real node staging and route validation", async () => {
|
||||
const gatewayRoot = await createTempRoot("openclaw-browser-proxy-gateway-root-");
|
||||
const nodeRoot = await createTempRoot("openclaw-browser-proxy-node-root-");
|
||||
const gatewayUploadDir = path.join(gatewayRoot, "uploads");
|
||||
const nodeUploadDir = path.join(nodeRoot, "uploads");
|
||||
await fs.mkdir(gatewayUploadDir, { recursive: true });
|
||||
const sourcePath = path.join(gatewayUploadDir, "report.txt");
|
||||
await fs.writeFile(sourcePath, "cross-host bytes", "utf8");
|
||||
|
||||
const prepared = await prepareBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: [sourcePath], ref: "e12" },
|
||||
uploadDir: gatewayUploadDir,
|
||||
inboundMediaDir: path.join(gatewayRoot, "inbound"),
|
||||
});
|
||||
if (!prepared.upload) {
|
||||
throw new Error("expected browser proxy upload envelope");
|
||||
}
|
||||
const staged = await stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: prepared.body,
|
||||
upload: prepared.upload,
|
||||
uploadDir: nodeUploadDir,
|
||||
});
|
||||
const stagedPaths = (staged.body as { paths: string[] }).paths;
|
||||
|
||||
expect(stagedPaths).toHaveLength(1);
|
||||
expect(stagedPaths[0]?.startsWith(`${nodeUploadDir}${path.sep}`)).toBe(true);
|
||||
await expect(fs.readFile(stagedPaths[0] ?? "", "utf8")).resolves.toBe("cross-host bytes");
|
||||
await expect(
|
||||
resolveExistingUploadPaths({
|
||||
requestedPaths: stagedPaths,
|
||||
uploadDir: nodeUploadDir,
|
||||
inboundMediaDir: path.join(nodeRoot, "inbound"),
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, paths: stagedPaths });
|
||||
|
||||
await discardStagedBrowserProxyUpload(staged);
|
||||
});
|
||||
|
||||
it("round-trips the exact 16 MiB aggregate file limit", async () => {
|
||||
const gatewayRoot = await createTempRoot("openclaw-browser-proxy-limit-gateway-");
|
||||
const nodeRoot = await createTempRoot("openclaw-browser-proxy-limit-node-");
|
||||
const gatewayUploadDir = path.join(gatewayRoot, "uploads");
|
||||
const nodeUploadDir = path.join(nodeRoot, "uploads");
|
||||
await fs.mkdir(gatewayUploadDir, { recursive: true });
|
||||
const firstPath = path.join(gatewayUploadDir, "first.bin");
|
||||
const secondPath = path.join(gatewayUploadDir, "second.bin");
|
||||
await fs.writeFile(firstPath, Buffer.alloc(10 * 1024 * 1024, 0x61));
|
||||
await fs.writeFile(secondPath, Buffer.alloc(6 * 1024 * 1024, 0x62));
|
||||
|
||||
const prepared = await prepareBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: [firstPath, secondPath] },
|
||||
uploadDir: gatewayUploadDir,
|
||||
inboundMediaDir: path.join(gatewayRoot, "inbound"),
|
||||
});
|
||||
if (!prepared.upload) {
|
||||
throw new Error("expected browser proxy upload envelope");
|
||||
}
|
||||
const staged = await stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: prepared.body,
|
||||
upload: prepared.upload,
|
||||
uploadDir: nodeUploadDir,
|
||||
});
|
||||
const stagedPaths = (staged.body as { paths: string[] }).paths;
|
||||
|
||||
await expect(fs.stat(stagedPaths[0] ?? "")).resolves.toMatchObject({
|
||||
size: 10 * 1024 * 1024,
|
||||
});
|
||||
await expect(fs.stat(stagedPaths[1] ?? "")).resolves.toMatchObject({
|
||||
size: 6 * 1024 * 1024,
|
||||
});
|
||||
await discardStagedBrowserProxyUpload(staged);
|
||||
});
|
||||
|
||||
it("stages files under the node upload root with route-valid paths", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-node-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
const upload: BrowserProxyUploadV1 = {
|
||||
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
files: [
|
||||
{ name: "report.txt", contentBase64: Buffer.from("node copy").toString("base64") },
|
||||
{ name: "report.txt", contentBase64: Buffer.from("second copy").toString("base64") },
|
||||
],
|
||||
};
|
||||
|
||||
const staged = await stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { ref: "e12" },
|
||||
upload,
|
||||
uploadDir,
|
||||
});
|
||||
const stagedPaths = (staged.body as { paths: string[] }).paths;
|
||||
|
||||
expect(stagedPaths).toHaveLength(2);
|
||||
expect(stagedPaths.map((filePath) => path.basename(filePath))).toEqual([
|
||||
"report.txt",
|
||||
"report.txt",
|
||||
]);
|
||||
await expect(fs.readFile(stagedPaths[0] ?? "", "utf8")).resolves.toBe("node copy");
|
||||
await expect(fs.readFile(stagedPaths[1] ?? "", "utf8")).resolves.toBe("second copy");
|
||||
await expect(
|
||||
resolveExistingUploadPaths({
|
||||
requestedPaths: stagedPaths,
|
||||
uploadDir,
|
||||
inboundMediaDir: path.join(root, "inbound"),
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, paths: stagedPaths });
|
||||
|
||||
await discardStagedBrowserProxyUpload(staged);
|
||||
await expect(fs.stat(staged.directory ?? "")).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("rejects ambiguous request bodies and invalid encodings before dispatch", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-invalid-");
|
||||
const upload: BrowserProxyUploadV1 = {
|
||||
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
files: [{ name: "report.txt", contentBase64: "not-base64" }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: ["/gateway/report.txt"] },
|
||||
upload,
|
||||
uploadDir: path.join(root, "uploads"),
|
||||
}),
|
||||
).rejects.toThrow("browser proxy upload body must omit paths");
|
||||
|
||||
await expect(
|
||||
stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: {},
|
||||
upload,
|
||||
uploadDir: path.join(root, "uploads"),
|
||||
}),
|
||||
).rejects.toThrow("invalid browser proxy upload encoding");
|
||||
});
|
||||
|
||||
it("rejects malformed Gateway path arrays instead of forwarding them to the node", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-malformed-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
const sourcePath = path.join(uploadDir, "report.txt");
|
||||
await fs.writeFile(sourcePath, "gateway bytes", "utf8");
|
||||
|
||||
await expect(
|
||||
prepareBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: { paths: [sourcePath, 42] },
|
||||
uploadDir,
|
||||
inboundMediaDir: path.join(root, "inbound"),
|
||||
}),
|
||||
).rejects.toThrow("browser proxy upload paths must contain only strings");
|
||||
});
|
||||
|
||||
it("uses portable names for Windows-reserved aliases", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-portable-");
|
||||
const staged = await stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: {},
|
||||
upload: {
|
||||
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
files: [{ name: "COM¹.txt", contentBase64: "aGVsbG8=" }],
|
||||
},
|
||||
uploadDir: path.join(root, "uploads"),
|
||||
});
|
||||
|
||||
expect(path.basename((staged.body as { paths: string[] }).paths[0] ?? "")).toBe("_COM¹.txt");
|
||||
await discardStagedBrowserProxyUpload(staged);
|
||||
});
|
||||
|
||||
it("enforces retained byte and directory limits across concurrent requests", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-limits-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
const upload: BrowserProxyUploadV1 = {
|
||||
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
files: [{ name: "report.txt", contentBase64: Buffer.from("data").toString("base64") }],
|
||||
};
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: {},
|
||||
upload,
|
||||
uploadDir,
|
||||
maxRetainedBytes: 1024,
|
||||
maxRetainedDirectories: 1,
|
||||
}),
|
||||
stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: {},
|
||||
upload,
|
||||
uploadDir,
|
||||
maxRetainedBytes: 1024,
|
||||
maxRetainedDirectories: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(results.map((result) => result.status).toSorted()).toEqual(["fulfilled", "rejected"]);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
expect(rejected).toMatchObject({
|
||||
reason: expect.objectContaining({
|
||||
message: "RESOURCE_EXHAUSTED: browser proxy upload staging limit reached",
|
||||
}),
|
||||
});
|
||||
const fulfilled = results.find(
|
||||
(
|
||||
result,
|
||||
): result is PromiseFulfilledResult<
|
||||
Awaited<ReturnType<typeof stageBrowserProxyUploadRequest>>
|
||||
> => result.status === "fulfilled",
|
||||
);
|
||||
if (fulfilled) {
|
||||
await discardStagedBrowserProxyUpload(fulfilled.value);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects staging when retained bytes alone exceed the quota", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-byte-limit-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
const upload: BrowserProxyUploadV1 = {
|
||||
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
files: [{ name: "report.txt", contentBase64: Buffer.from("data").toString("base64") }],
|
||||
};
|
||||
const first = await stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: {},
|
||||
upload,
|
||||
uploadDir,
|
||||
maxRetainedBytes: 40,
|
||||
maxRetainedDirectories: 10,
|
||||
});
|
||||
|
||||
await expect(
|
||||
stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: {},
|
||||
upload,
|
||||
uploadDir,
|
||||
maxRetainedBytes: 40,
|
||||
maxRetainedDirectories: 10,
|
||||
}),
|
||||
).rejects.toThrow("browser proxy upload staging limit reached");
|
||||
await discardStagedBrowserProxyUpload(first);
|
||||
});
|
||||
|
||||
it("counts the ownership marker against retained-byte admission", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-marker-limit-");
|
||||
await expect(
|
||||
stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
body: {},
|
||||
upload: {
|
||||
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
files: [{ name: "report.txt", contentBase64: Buffer.from("data").toString("base64") }],
|
||||
},
|
||||
uploadDir: path.join(root, "uploads"),
|
||||
maxRetainedBytes: 4,
|
||||
maxRetainedDirectories: 10,
|
||||
}),
|
||||
).rejects.toThrow("browser proxy upload staging limit reached");
|
||||
});
|
||||
|
||||
it("removes expired staged directories during startup recovery", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-recovery-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
const staleDirectory = path.join(uploadDir, ".proxy-uploads", "upload-stale");
|
||||
await fs.mkdir(staleDirectory, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(staleDirectory, ".openclaw-browser-proxy-upload-v1"),
|
||||
"openclaw-browser-proxy-upload-v1\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(staleDirectory, "report.txt"), "stale", "utf8");
|
||||
const staleTime = new Date(Date.now() - 10_000);
|
||||
await fs.utimes(staleDirectory, staleTime, staleTime);
|
||||
|
||||
await ensureBrowserProxyUploadCleanup({
|
||||
uploadDir,
|
||||
retentionMs: 1,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
|
||||
await expect(fs.stat(staleDirectory)).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("evicts the oldest retained directory during restart recovery when over quota", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-recovery-quota-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
const stagingRoot = path.join(uploadDir, ".proxy-uploads");
|
||||
const oldestDirectory = path.join(stagingRoot, "upload-oldest");
|
||||
const newestDirectory = path.join(stagingRoot, "upload-newest");
|
||||
for (const [directory, contents] of [
|
||||
[oldestDirectory, "old"],
|
||||
[newestDirectory, "new"],
|
||||
] as const) {
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(directory, ".openclaw-browser-proxy-upload-v1"),
|
||||
"openclaw-browser-proxy-upload-v1\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(directory, "report.txt"), contents, "utf8");
|
||||
}
|
||||
const now = Date.now();
|
||||
await fs.utimes(oldestDirectory, new Date(now - 2_000), new Date(now - 2_000));
|
||||
await fs.utimes(newestDirectory, new Date(now - 1_000), new Date(now - 1_000));
|
||||
|
||||
await ensureBrowserProxyUploadCleanup({
|
||||
uploadDir,
|
||||
nowMs: now,
|
||||
maxRetainedBytes: 50,
|
||||
maxRetainedDirectories: 10,
|
||||
});
|
||||
|
||||
await expect(fs.stat(oldestDirectory)).rejects.toHaveProperty("code", "ENOENT");
|
||||
await expect(fs.readFile(path.join(newestDirectory, "report.txt"), "utf8")).resolves.toBe(
|
||||
"new",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not recover unmarked directories from the private staging root", async () => {
|
||||
const root = await createTempRoot("openclaw-browser-proxy-recovery-unmarked-");
|
||||
const uploadDir = path.join(root, "uploads");
|
||||
const unrelatedDirectory = path.join(uploadDir, ".proxy-uploads", "upload-archive");
|
||||
await fs.mkdir(unrelatedDirectory, { recursive: true });
|
||||
await fs.writeFile(path.join(unrelatedDirectory, "report.txt"), "keep", "utf8");
|
||||
const staleTime = new Date(Date.now() - 10_000);
|
||||
await fs.utimes(unrelatedDirectory, staleTime, staleTime);
|
||||
|
||||
await ensureBrowserProxyUploadCleanup({
|
||||
uploadDir,
|
||||
retentionMs: 1,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(unrelatedDirectory, "report.txt"), "utf8")).resolves.toBe(
|
||||
"keep",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves unrelated proxy requests unchanged", async () => {
|
||||
const body = { paths: ["/tmp/openclaw/uploads/report.txt"] };
|
||||
await expect(
|
||||
prepareBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
body,
|
||||
}),
|
||||
).resolves.toEqual({ body });
|
||||
await expect(
|
||||
stageBrowserProxyUploadRequest({
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
body,
|
||||
}),
|
||||
).resolves.toEqual({ body });
|
||||
});
|
||||
});
|
||||
583
extensions/browser/src/browser-proxy-upload.ts
Normal file
583
extensions/browser/src/browser-proxy-upload.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Browser proxy upload transport.
|
||||
*
|
||||
* Existing Browser upload paths are Gateway-owned. Proxied requests carry
|
||||
* bounded bytes to the node, which stages private copies under its upload root.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
assertBrowserProxyFileBytesWithinLimits,
|
||||
assertBrowserProxyFileCountWithinLimit,
|
||||
BROWSER_PROXY_MAX_FILE_BYTES,
|
||||
BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
type BrowserProxyUploadFile,
|
||||
type BrowserProxyUploadV1,
|
||||
} from "./browser-proxy-envelope.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveExistingUploadPaths } from "./browser/paths.js";
|
||||
|
||||
const logger = createSubsystemLogger("browser");
|
||||
const BROWSER_PROXY_UPLOAD_ROOT_NAME = ".proxy-uploads";
|
||||
const BROWSER_PROXY_UPLOAD_PREFIX = "upload-";
|
||||
const BROWSER_PROXY_UPLOAD_MARKER_NAME = ".openclaw-browser-proxy-upload-v1";
|
||||
const BROWSER_PROXY_UPLOAD_MARKER_CONTENT = "openclaw-browser-proxy-upload-v1\n";
|
||||
const BROWSER_PROXY_UPLOAD_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
const BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS = 60 * 60 * 1000;
|
||||
const BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES = 256 * 1024 * 1024;
|
||||
const BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES = 64;
|
||||
const BROWSER_PROXY_MAX_ENCODED_FILE_LENGTH = Math.ceil(BROWSER_PROXY_MAX_FILE_BYTES / 3) * 4;
|
||||
const MAX_STAGED_NAME_BYTES = 180;
|
||||
const PORTABLE_NAME_FORBIDDEN = new Set(["<", ">", ":", '"', "/", "\\", "|", "?", "*", "%", "!"]);
|
||||
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu;
|
||||
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const recoveryPromises = new Map<string, Promise<void>>();
|
||||
const recoveryRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const stagingLocks = new Map<string, Promise<void>>();
|
||||
|
||||
type PreparedBrowserProxyUploadRequest = {
|
||||
body: unknown;
|
||||
upload?: BrowserProxyUploadV1;
|
||||
};
|
||||
|
||||
type StagedBrowserProxyUploadRequest = {
|
||||
body: unknown;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
type StagedUploadLimits = {
|
||||
maxRetainedBytes: number;
|
||||
maxRetainedDirectories: number;
|
||||
};
|
||||
|
||||
type OwnedStagedUpload = {
|
||||
bytes: number;
|
||||
directory: string;
|
||||
mtimeMs: number;
|
||||
};
|
||||
|
||||
function isFileChooserRequest(method: string, requestPath: string): boolean {
|
||||
return (
|
||||
method.toUpperCase() === "POST" &&
|
||||
`/${requestPath.trim().replace(/^\/+/u, "")}` === "/hooks/file-chooser"
|
||||
);
|
||||
}
|
||||
|
||||
/** Identify upload requests before reading files or applying transport-only limits. */
|
||||
export function isBrowserProxyUploadRequest(params: {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
}): boolean {
|
||||
if (!isFileChooserRequest(params.method, params.path)) {
|
||||
return false;
|
||||
}
|
||||
const body = asRecord(params.body);
|
||||
return Boolean(body && Array.isArray(body.paths) && body.paths.length > 0);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readUploadPaths(body: Record<string, unknown>): string[] | null {
|
||||
if (!Array.isArray(body.paths) || body.paths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!body.paths.every((entry): entry is string => typeof entry === "string")) {
|
||||
throw new Error("browser proxy upload paths must contain only strings");
|
||||
}
|
||||
return body.paths;
|
||||
}
|
||||
|
||||
async function readBrowserProxyUploadFiles(
|
||||
paths: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserProxyUploadFile[]> {
|
||||
assertBrowserProxyFileCountWithinLimit(paths.length, "request");
|
||||
const files: BrowserProxyUploadFile[] = [];
|
||||
let totalBytes = 0;
|
||||
for (const filePath of paths) {
|
||||
signal?.throwIfAborted();
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat?.isFile()) {
|
||||
throw new Error(`browser proxy upload file not found: ${filePath}`);
|
||||
}
|
||||
assertBrowserProxyFileBytesWithinLimits(stat.size, totalBytes + stat.size);
|
||||
const buffer = await fs.readFile(filePath, signal ? { signal } : undefined);
|
||||
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength);
|
||||
totalBytes += buffer.byteLength;
|
||||
files.push({
|
||||
name: path.basename(filePath),
|
||||
contentBase64: buffer.toString("base64"),
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Build a node-only upload envelope while retaining the original body for host fallback. */
|
||||
export async function prepareBrowserProxyUploadRequest(params: {
|
||||
method: string;
|
||||
path: string;
|
||||
body: unknown;
|
||||
uploadDir?: string;
|
||||
inboundMediaDir?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PreparedBrowserProxyUploadRequest> {
|
||||
params.signal?.throwIfAborted();
|
||||
if (!isFileChooserRequest(params.method, params.path)) {
|
||||
return { body: params.body };
|
||||
}
|
||||
const body = asRecord(params.body);
|
||||
if (!body) {
|
||||
return { body: params.body };
|
||||
}
|
||||
const requestedPaths = readUploadPaths(body);
|
||||
if (!requestedPaths) {
|
||||
return { body: params.body };
|
||||
}
|
||||
assertBrowserProxyFileCountWithinLimit(requestedPaths.length, "request");
|
||||
const resolved = await resolveExistingUploadPaths({
|
||||
requestedPaths,
|
||||
...(params.uploadDir ? { uploadDir: params.uploadDir } : {}),
|
||||
...(params.inboundMediaDir ? { inboundMediaDir: params.inboundMediaDir } : {}),
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
throw new Error(resolved.error);
|
||||
}
|
||||
const upload: BrowserProxyUploadV1 = {
|
||||
envelope: BROWSER_PROXY_UPLOAD_ENVELOPE,
|
||||
files: await readBrowserProxyUploadFiles(resolved.paths, params.signal),
|
||||
};
|
||||
const { paths: _paths, ...bodyWithoutPaths } = body;
|
||||
return { body: bodyWithoutPaths, upload };
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
let result = "";
|
||||
let bytes = 0;
|
||||
for (const character of value) {
|
||||
const nextBytes = Buffer.byteLength(character, "utf8");
|
||||
if (bytes + nextBytes > maxBytes) {
|
||||
break;
|
||||
}
|
||||
result += character;
|
||||
bytes += nextBytes;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sanitizeUploadName(name: string): string {
|
||||
const basename = path.posix.basename(name.replaceAll("\\", "/"));
|
||||
const cleaned = Array.from(basename, (character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f || PORTABLE_NAME_FORBIDDEN.has(character)
|
||||
? "_"
|
||||
: character;
|
||||
})
|
||||
.join("")
|
||||
.trim()
|
||||
.replace(/[. ]+$/u, "");
|
||||
const portable = WINDOWS_RESERVED_NAME.test(cleaned) ? `_${cleaned}` : cleaned;
|
||||
const safe = portable && portable !== "." && portable !== ".." ? portable : "upload";
|
||||
return truncateUtf8(safe, MAX_STAGED_NAME_BYTES) || "upload";
|
||||
}
|
||||
|
||||
function decodedBase64Size(value: string): number {
|
||||
if (value.length % 4 !== 0) {
|
||||
throw new Error("INVALID_REQUEST: invalid browser proxy upload encoding");
|
||||
}
|
||||
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
||||
return (value.length / 4) * 3 - padding;
|
||||
}
|
||||
|
||||
function decodeUploadFile(file: BrowserProxyUploadFile, totalBytes: number): Buffer {
|
||||
if (
|
||||
!file ||
|
||||
typeof file.name !== "string" ||
|
||||
!file.name.trim() ||
|
||||
typeof file.contentBase64 !== "string" ||
|
||||
file.contentBase64.length > BROWSER_PROXY_MAX_ENCODED_FILE_LENGTH
|
||||
) {
|
||||
throw new Error("INVALID_REQUEST: invalid browser proxy upload file");
|
||||
}
|
||||
const estimatedBytes = decodedBase64Size(file.contentBase64);
|
||||
assertBrowserProxyFileBytesWithinLimits(estimatedBytes, totalBytes + estimatedBytes);
|
||||
const buffer = Buffer.from(file.contentBase64, "base64");
|
||||
if (buffer.toString("base64") !== file.contentBase64) {
|
||||
throw new Error("INVALID_REQUEST: invalid browser proxy upload encoding");
|
||||
}
|
||||
assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function removeStagedUpload(directory: string): Promise<void> {
|
||||
const timer = cleanupTimers.get(directory);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
cleanupTimers.delete(directory);
|
||||
}
|
||||
try {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
logger.warn(`browser proxy upload cleanup failed; retrying: ${String(error)}`);
|
||||
scheduleCleanup(directory, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function readDirectoryBytes(directory: string, signal?: AbortSignal): Promise<number> {
|
||||
signal?.throwIfAborted();
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let totalBytes = 0;
|
||||
for (const entry of entries) {
|
||||
signal?.throwIfAborted();
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
totalBytes += await readDirectoryBytes(entryPath, signal);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const stat = await fs.stat(entryPath).catch(() => null);
|
||||
totalBytes += stat?.isFile() ? stat.size : 0;
|
||||
}
|
||||
return totalBytes;
|
||||
}
|
||||
|
||||
async function readOwnedStagedUploads(
|
||||
stagingRoot: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OwnedStagedUpload[]> {
|
||||
signal?.throwIfAborted();
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(stagingRoot, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const uploads = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(BROWSER_PROXY_UPLOAD_PREFIX))
|
||||
.map(async (entry): Promise<OwnedStagedUpload | null> => {
|
||||
signal?.throwIfAborted();
|
||||
const directory = path.join(stagingRoot, entry.name);
|
||||
const stat = await fs.stat(directory).catch(() => null);
|
||||
if (!stat?.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
||||
return null;
|
||||
}
|
||||
const marker = await fs
|
||||
.readFile(path.join(directory, BROWSER_PROXY_UPLOAD_MARKER_NAME), "utf8")
|
||||
.catch(() => null);
|
||||
if (marker !== BROWSER_PROXY_UPLOAD_MARKER_CONTENT) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bytes: await readDirectoryBytes(directory, signal),
|
||||
directory,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return uploads.filter((upload): upload is OwnedStagedUpload => upload !== null);
|
||||
}
|
||||
|
||||
function scheduleCleanup(directory: string, delayMs: number): void {
|
||||
if (cleanupTimers.has(directory)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
cleanupTimers.delete(directory);
|
||||
void removeStagedUpload(directory);
|
||||
},
|
||||
Math.max(0, delayMs),
|
||||
);
|
||||
cleanupTimers.set(directory, timer);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
async function recoverStagedUploads(params: {
|
||||
uploadDir: string;
|
||||
retentionMs: number;
|
||||
nowMs: number;
|
||||
limits: StagedUploadLimits;
|
||||
}): Promise<void> {
|
||||
const { uploadDir, retentionMs, nowMs, limits } = params;
|
||||
const stagingRoot = path.join(uploadDir, BROWSER_PROXY_UPLOAD_ROOT_NAME);
|
||||
const retained: OwnedStagedUpload[] = [];
|
||||
for (const upload of await readOwnedStagedUploads(stagingRoot)) {
|
||||
if (retentionMs - Math.max(0, nowMs - upload.mtimeMs) <= 0) {
|
||||
await removeStagedUpload(upload.directory);
|
||||
} else {
|
||||
retained.push(upload);
|
||||
}
|
||||
}
|
||||
|
||||
// A restarted node has no surviving Browser request that can still own these
|
||||
// copies, so oldest-first eviction safely restores the bounded retention budget.
|
||||
retained.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
||||
let totalBytes = retained.reduce((total, upload) => total + upload.bytes, 0);
|
||||
while (retained.length > limits.maxRetainedDirectories || totalBytes > limits.maxRetainedBytes) {
|
||||
const oldest = retained.shift();
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
totalBytes -= oldest.bytes;
|
||||
await removeStagedUpload(oldest.directory);
|
||||
}
|
||||
for (const upload of retained) {
|
||||
const remaining = retentionMs - Math.max(0, nowMs - upload.mtimeMs);
|
||||
scheduleCleanup(upload.directory, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecoveryRetry(uploadDir: string): void {
|
||||
const timer = recoveryRetryTimers.get(uploadDir);
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
recoveryRetryTimers.delete(uploadDir);
|
||||
}
|
||||
|
||||
function scheduleRecoveryRetry(uploadDir: string, retentionMs: number): void {
|
||||
if (recoveryRetryTimers.has(uploadDir)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
recoveryRetryTimers.delete(uploadDir);
|
||||
recoveryPromises.delete(uploadDir);
|
||||
void ensureBrowserProxyUploadCleanup({ uploadDir, retentionMs });
|
||||
}, BROWSER_PROXY_UPLOAD_CLEANUP_RETRY_MS);
|
||||
recoveryRetryTimers.set(uploadDir, timer);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
async function runRecovery(params: {
|
||||
uploadDir: string;
|
||||
retentionMs: number;
|
||||
nowMs: number;
|
||||
limits: StagedUploadLimits;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await recoverStagedUploads(params);
|
||||
clearRecoveryRetry(params.uploadDir);
|
||||
} catch (error) {
|
||||
logger.warn(`browser proxy upload recovery failed; retrying: ${String(error)}`);
|
||||
scheduleRecoveryRetry(params.uploadDir, params.retentionMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores cleanup timers for staged uploads left by a previous node process. */
|
||||
export function ensureBrowserProxyUploadCleanup(options?: {
|
||||
uploadDir?: string;
|
||||
retentionMs?: number;
|
||||
nowMs?: number;
|
||||
maxRetainedBytes?: number;
|
||||
maxRetainedDirectories?: number;
|
||||
}): Promise<void> {
|
||||
const uploadDir = options?.uploadDir ?? DEFAULT_UPLOAD_DIR;
|
||||
const retentionMs = options?.retentionMs ?? BROWSER_PROXY_UPLOAD_RETENTION_MS;
|
||||
const nowMs = options?.nowMs ?? Date.now();
|
||||
const limits = {
|
||||
maxRetainedBytes: options?.maxRetainedBytes ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES,
|
||||
maxRetainedDirectories:
|
||||
options?.maxRetainedDirectories ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES,
|
||||
};
|
||||
if (
|
||||
options?.retentionMs !== undefined ||
|
||||
options?.nowMs !== undefined ||
|
||||
options?.maxRetainedBytes !== undefined ||
|
||||
options?.maxRetainedDirectories !== undefined
|
||||
) {
|
||||
return runRecovery({ uploadDir, retentionMs, nowMs, limits });
|
||||
}
|
||||
const existing = recoveryPromises.get(uploadDir);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const recovery = runRecovery({ uploadDir, retentionMs, nowMs, limits }).finally(() => {
|
||||
if (recoveryRetryTimers.has(uploadDir)) {
|
||||
recoveryPromises.delete(uploadDir);
|
||||
}
|
||||
});
|
||||
recoveryPromises.set(uploadDir, recovery);
|
||||
return recovery;
|
||||
}
|
||||
|
||||
async function waitForStagingLock(previous: Promise<void>, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await previous;
|
||||
return;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
onAbort = () => {
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
await Promise.race([previous, aborted]);
|
||||
} finally {
|
||||
if (onAbort) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withStagingLock<T>(
|
||||
uploadDir: string,
|
||||
task: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const previous = stagingLocks.get(uploadDir) ?? Promise.resolve();
|
||||
let release = () => {};
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const tail = previous.then(() => current);
|
||||
stagingLocks.set(uploadDir, tail);
|
||||
try {
|
||||
await waitForStagingLock(previous, signal);
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
if (stagingLocks.get(uploadDir) === tail) {
|
||||
stagingLocks.delete(uploadDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateUploadEnvelope(upload: BrowserProxyUploadV1): BrowserProxyUploadFile[] {
|
||||
if (
|
||||
!upload ||
|
||||
upload.envelope !== BROWSER_PROXY_UPLOAD_ENVELOPE ||
|
||||
!Array.isArray(upload.files) ||
|
||||
upload.files.length === 0
|
||||
) {
|
||||
throw new Error("INVALID_REQUEST: invalid browser proxy upload envelope");
|
||||
}
|
||||
assertBrowserProxyFileCountWithinLimit(upload.files.length, "request");
|
||||
return upload.files;
|
||||
}
|
||||
|
||||
/** Stage a validated upload envelope under the node's managed Browser upload root. */
|
||||
export async function stageBrowserProxyUploadRequest(params: {
|
||||
method: string;
|
||||
path: string;
|
||||
body: unknown;
|
||||
upload?: BrowserProxyUploadV1;
|
||||
uploadDir?: string;
|
||||
maxRetainedBytes?: number;
|
||||
maxRetainedDirectories?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<StagedBrowserProxyUploadRequest> {
|
||||
params.signal?.throwIfAborted();
|
||||
if (params.upload === undefined) {
|
||||
return { body: params.body };
|
||||
}
|
||||
if (!isFileChooserRequest(params.method, params.path)) {
|
||||
throw new Error("INVALID_REQUEST: browser proxy upload requires the file chooser route");
|
||||
}
|
||||
const body = asRecord(params.body);
|
||||
if (!body || Object.hasOwn(body, "paths")) {
|
||||
throw new Error("INVALID_REQUEST: browser proxy upload body must omit paths");
|
||||
}
|
||||
const files = validateUploadEnvelope(params.upload);
|
||||
const uploadDir = params.uploadDir ?? DEFAULT_UPLOAD_DIR;
|
||||
const stagingRoot = path.join(uploadDir, BROWSER_PROXY_UPLOAD_ROOT_NAME);
|
||||
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
||||
params.signal?.throwIfAborted();
|
||||
await ensureBrowserProxyUploadCleanup({ uploadDir });
|
||||
params.signal?.throwIfAborted();
|
||||
const decodedFiles: Array<{ buffer: Buffer; name: string }> = [];
|
||||
let fileBytes = 0;
|
||||
for (const file of files) {
|
||||
const buffer = decodeUploadFile(file, fileBytes);
|
||||
fileBytes += buffer.byteLength;
|
||||
decodedFiles.push({ buffer, name: file.name });
|
||||
}
|
||||
const retainedRequestBytes = fileBytes + Buffer.byteLength(BROWSER_PROXY_UPLOAD_MARKER_CONTENT);
|
||||
const limits = {
|
||||
maxRetainedBytes: params.maxRetainedBytes ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_BYTES,
|
||||
maxRetainedDirectories:
|
||||
params.maxRetainedDirectories ?? BROWSER_PROXY_UPLOAD_MAX_RETAINED_DIRECTORIES,
|
||||
};
|
||||
return await withStagingLock(
|
||||
uploadDir,
|
||||
async () => {
|
||||
params.signal?.throwIfAborted();
|
||||
const retained = await readOwnedStagedUploads(stagingRoot, params.signal);
|
||||
const retainedBytes = retained.reduce((total, upload) => total + upload.bytes, 0);
|
||||
if (
|
||||
retained.length >= limits.maxRetainedDirectories ||
|
||||
retainedBytes + retainedRequestBytes > limits.maxRetainedBytes
|
||||
) {
|
||||
throw new Error("RESOURCE_EXHAUSTED: browser proxy upload staging limit reached");
|
||||
}
|
||||
const directory = await fs.mkdtemp(path.join(stagingRoot, BROWSER_PROXY_UPLOAD_PREFIX));
|
||||
const stagedPaths: string[] = [];
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(directory, BROWSER_PROXY_UPLOAD_MARKER_NAME),
|
||||
BROWSER_PROXY_UPLOAD_MARKER_CONTENT,
|
||||
{ flag: "wx", mode: 0o600, signal: params.signal },
|
||||
);
|
||||
for (const [index, file] of decodedFiles.entries()) {
|
||||
params.signal?.throwIfAborted();
|
||||
const fileDirectory = path.join(directory, String(index));
|
||||
await fs.mkdir(fileDirectory, { mode: 0o700 });
|
||||
params.signal?.throwIfAborted();
|
||||
const filePath = path.join(fileDirectory, sanitizeUploadName(file.name));
|
||||
await fs.writeFile(filePath, file.buffer, {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
signal: params.signal,
|
||||
});
|
||||
stagedPaths.push(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
await removeStagedUpload(directory);
|
||||
throw error;
|
||||
}
|
||||
scheduleCleanup(directory, BROWSER_PROXY_UPLOAD_RETENTION_MS);
|
||||
return {
|
||||
body: { ...body, paths: stagedPaths },
|
||||
directory,
|
||||
};
|
||||
},
|
||||
params.signal,
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove staged copies after a request fails before Browser ownership is established. */
|
||||
export async function discardStagedBrowserProxyUpload(
|
||||
staged: StagedBrowserProxyUploadRequest,
|
||||
): Promise<void> {
|
||||
if (staged.directory) {
|
||||
await removeStagedUpload(staged.directory);
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BROWSER_PROXY_COMMAND } from "./browser-node-commands.js";
|
||||
/**
|
||||
* Browser agent tool registration.
|
||||
*
|
||||
@@ -213,12 +214,14 @@ function readActRequestParam(params: Record<string, unknown>) {
|
||||
type BrowserNodeTarget = {
|
||||
nodeId: string;
|
||||
label?: string;
|
||||
commands: string[];
|
||||
pendingDeclaredCommands: string[];
|
||||
};
|
||||
|
||||
function isBrowserNode(node: NodeListNode) {
|
||||
const caps = Array.isArray(node.caps) ? node.caps : [];
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
return caps.includes("browser") || commands.includes("browser.proxy");
|
||||
return caps.includes("browser") || commands.includes(BROWSER_PROXY_COMMAND);
|
||||
}
|
||||
|
||||
async function resolveBrowserNodeTarget(params: {
|
||||
@@ -268,7 +271,14 @@ async function resolveBrowserNodeTarget(params: {
|
||||
allowCompactDisplayName: true,
|
||||
});
|
||||
const node = browserNodes.find((entry) => entry.nodeId === nodeId);
|
||||
return { nodeId, label: node?.displayName ?? node?.remoteIp ?? nodeId };
|
||||
return {
|
||||
nodeId,
|
||||
label: node?.displayName ?? node?.remoteIp ?? nodeId,
|
||||
commands: Array.isArray(node?.commands) ? node.commands : [],
|
||||
pendingDeclaredCommands: Array.isArray(node?.pendingDeclaredCommands)
|
||||
? node.pendingDeclaredCommands
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectDefaultNodeFromList(browserNodes, {
|
||||
@@ -281,6 +291,10 @@ async function resolveBrowserNodeTarget(params: {
|
||||
return {
|
||||
nodeId: selected.nodeId,
|
||||
label: selected.displayName ?? selected.remoteIp ?? selected.nodeId,
|
||||
commands: Array.isArray(selected.commands) ? selected.commands : [],
|
||||
pendingDeclaredCommands: Array.isArray(selected.pendingDeclaredCommands)
|
||||
? selected.pendingDeclaredCommands
|
||||
: [],
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
@@ -296,6 +310,10 @@ async function resolveBrowserNodeTarget(params: {
|
||||
return {
|
||||
nodeId: selected.nodeId,
|
||||
label: selected.displayName ?? selected.remoteIp ?? selected.nodeId,
|
||||
commands: Array.isArray(selected.commands) ? selected.commands : [],
|
||||
pendingDeclaredCommands: Array.isArray(selected.pendingDeclaredCommands)
|
||||
? selected.pendingDeclaredCommands
|
||||
: [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -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" } } },
|
||||
|
||||
@@ -6,6 +6,11 @@ import crypto from "node:crypto";
|
||||
import { clampTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
BROWSER_PROXY_COMMAND,
|
||||
BROWSER_PROXY_UPLOAD_COMMAND,
|
||||
browserProxyUploadUnavailableMessage,
|
||||
} from "../browser-node-commands.js";
|
||||
import { isBrowserControlHostUnavailableError } from "../browser-node-fallback.js";
|
||||
import {
|
||||
BROWSER_PROXY_ERROR_ENVELOPE,
|
||||
@@ -14,6 +19,10 @@ import {
|
||||
type BrowserProxyFile,
|
||||
type BrowserProxySuccess,
|
||||
} from "../browser-proxy-envelope.js";
|
||||
import {
|
||||
isBrowserProxyUploadRequest,
|
||||
prepareBrowserProxyUploadRequest,
|
||||
} from "../browser-proxy-upload.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
applyBrowserProxyPaths,
|
||||
@@ -50,7 +59,7 @@ type BrowserRequestParams = {
|
||||
function isBrowserNode(node: NodeSession) {
|
||||
const caps = Array.isArray(node.caps) ? node.caps : [];
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
return caps.includes("browser") || commands.includes("browser.proxy");
|
||||
return caps.includes("browser") || commands.includes(BROWSER_PROXY_COMMAND);
|
||||
}
|
||||
|
||||
function resolveBrowserNode(nodes: NodeSession[], query: string): NodeSession | null {
|
||||
@@ -151,32 +160,68 @@ export async function handleBrowserGatewayRequest({
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeTarget && isPersistentBrowserProfileMutation(methodRaw, path)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"browser.request cannot mutate persistent browser profiles over a node proxy",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let preparedUpload: Awaited<ReturnType<typeof prepareBrowserProxyUploadRequest>> | null = null;
|
||||
let proxyCommand = BROWSER_PROXY_COMMAND;
|
||||
if (nodeTarget) {
|
||||
if (isPersistentBrowserProfileMutation(methodRaw, path)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"browser.request cannot mutate persistent browser profiles over a node proxy",
|
||||
),
|
||||
if (
|
||||
isBrowserProxyUploadRequest({ method: methodRaw, path, body }) &&
|
||||
!nodeTarget.commands?.includes(BROWSER_PROXY_UPLOAD_COMMAND)
|
||||
) {
|
||||
const message = browserProxyUploadUnavailableMessage(nodeTarget.declaredCommands);
|
||||
if (configuredNode) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, message));
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
`browser node ${nodeTarget.displayName ?? nodeTarget.nodeId} lacks ${BROWSER_PROXY_UPLOAD_COMMAND}; falling back to Gateway host`,
|
||||
);
|
||||
nodeTarget = null;
|
||||
}
|
||||
}
|
||||
if (nodeTarget) {
|
||||
try {
|
||||
preparedUpload = await prepareBrowserProxyUploadRequest({
|
||||
method: methodRaw,
|
||||
path,
|
||||
body,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, message));
|
||||
return;
|
||||
}
|
||||
if (preparedUpload.upload) {
|
||||
proxyCommand = BROWSER_PROXY_UPLOAD_COMMAND;
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeTarget && preparedUpload) {
|
||||
const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget);
|
||||
const allowed = isNodeCommandAllowed({
|
||||
command: "browser.proxy",
|
||||
command: proxyCommand,
|
||||
declaredCommands: nodeTarget.commands,
|
||||
allowlist,
|
||||
});
|
||||
if (!allowed.ok) {
|
||||
const platform = nodeTarget.platform ?? "unknown";
|
||||
const hint = `node command not allowed: ${allowed.reason} (platform: ${platform}, command: browser.proxy)`;
|
||||
const hint = `node command not allowed: ${allowed.reason} (platform: ${platform}, command: ${proxyCommand})`;
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, hint, {
|
||||
details: { reason: allowed.reason, command: "browser.proxy" },
|
||||
details: { reason: allowed.reason, command: proxyCommand },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -186,14 +231,15 @@ export async function handleBrowserGatewayRequest({
|
||||
method: methodRaw,
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
body: preparedUpload.body,
|
||||
upload: preparedUpload.upload,
|
||||
timeoutMs,
|
||||
profile: resolveRequestedBrowserProfile({ query, body }),
|
||||
errorEnvelope: BROWSER_PROXY_ERROR_ENVELOPE,
|
||||
};
|
||||
const res = await context.nodeRegistry.invoke({
|
||||
nodeId: nodeTarget.nodeId,
|
||||
command: "browser.proxy",
|
||||
command: proxyCommand,
|
||||
params: proxyParams,
|
||||
timeoutMs,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import fsPromises from "node:fs/promises";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { BROWSER_PROXY_COMMAND, BROWSER_PROXY_UPLOAD_COMMAND } from "../browser-node-commands.js";
|
||||
import {
|
||||
assertBrowserProxyFileCountWithinLimit,
|
||||
assertBrowserProxyFileBytesWithinLimits,
|
||||
@@ -12,8 +13,14 @@ import {
|
||||
createBrowserProxyFailure,
|
||||
type BrowserProxyEnvelope,
|
||||
type BrowserProxyFile,
|
||||
type BrowserProxyUploadV1,
|
||||
visitBrowserProxyFilePaths,
|
||||
} from "../browser-proxy-envelope.js";
|
||||
import {
|
||||
discardStagedBrowserProxyUpload,
|
||||
ensureBrowserProxyUploadCleanup,
|
||||
stageBrowserProxyUploadRequest,
|
||||
} from "../browser-proxy-upload.js";
|
||||
import { redactCdpUrl } from "../browser/cdp.helpers.js";
|
||||
import { loadBrowserConfigForRuntimeRefresh } from "../browser/config-refresh-source.js";
|
||||
import { resolveBrowserConfig } from "../browser/config.js";
|
||||
@@ -39,6 +46,7 @@ type BrowserProxyParams = {
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
errorEnvelope?: unknown;
|
||||
upload?: BrowserProxyUploadV1;
|
||||
};
|
||||
|
||||
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
|
||||
@@ -147,6 +155,16 @@ function isBrowserProxyTimeoutError(err: unknown): boolean {
|
||||
return String(err).includes("browser proxy request timed out");
|
||||
}
|
||||
|
||||
function combineBrowserProxySignals(
|
||||
timeoutSignal: AbortSignal | undefined,
|
||||
invocationSignal: AbortSignal | undefined,
|
||||
): AbortSignal | undefined {
|
||||
if (timeoutSignal && invocationSignal) {
|
||||
return AbortSignal.any([timeoutSignal, invocationSignal]);
|
||||
}
|
||||
return timeoutSignal ?? invocationSignal;
|
||||
}
|
||||
|
||||
function isWsBackedBrowserProxyPath(path: string): boolean {
|
||||
return (
|
||||
path === "/act" ||
|
||||
@@ -226,8 +244,23 @@ function formatBrowserProxyTimeoutMessage(params: {
|
||||
}
|
||||
|
||||
/** Executes a serialized browser.proxy command and returns a serialized result payload. */
|
||||
export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise<string> {
|
||||
export async function runBrowserProxyCommand(
|
||||
paramsJSON?: string | null,
|
||||
command = BROWSER_PROXY_COMMAND,
|
||||
invocationSignal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
invocationSignal?.throwIfAborted();
|
||||
void ensureBrowserProxyUploadCleanup();
|
||||
const params = decodeParams<BrowserProxyParams>(paramsJSON);
|
||||
if (command === BROWSER_PROXY_COMMAND && params.upload !== undefined) {
|
||||
throw new Error("INVALID_REQUEST: browser.proxy does not accept upload envelopes");
|
||||
}
|
||||
if (command === BROWSER_PROXY_UPLOAD_COMMAND && !params.upload) {
|
||||
throw new Error("INVALID_REQUEST: browser.proxy.upload.v1 requires an upload envelope");
|
||||
}
|
||||
if (command !== BROWSER_PROXY_COMMAND && command !== BROWSER_PROXY_UPLOAD_COMMAND) {
|
||||
throw new Error(`INVALID_REQUEST: unsupported browser proxy command: ${command}`);
|
||||
}
|
||||
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
|
||||
if (!pathValue) {
|
||||
throw new Error("INVALID_REQUEST: path required");
|
||||
@@ -238,11 +271,12 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
}
|
||||
|
||||
await ensureBrowserControlService();
|
||||
invocationSignal?.throwIfAborted();
|
||||
const cfg = loadBrowserConfigForRuntimeRefresh();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
||||
const path = normalizeBrowserRequestPath(pathValue);
|
||||
const body = params.body;
|
||||
let body = params.body;
|
||||
const requestedProfile =
|
||||
resolveRequestedBrowserProfile({
|
||||
query: params.query,
|
||||
@@ -273,6 +307,7 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
}
|
||||
|
||||
const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs);
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
const query: Record<string, unknown> = {};
|
||||
const rawQuery = params.query ?? {};
|
||||
for (const [key, value] of Object.entries(rawQuery)) {
|
||||
@@ -286,18 +321,63 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
}
|
||||
|
||||
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
let stagedUpload;
|
||||
try {
|
||||
stagedUpload = await withTimeout(
|
||||
(timeoutSignal) =>
|
||||
stageBrowserProxyUploadRequest({
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
upload: params.upload,
|
||||
signal: combineBrowserProxySignals(timeoutSignal, invocationSignal),
|
||||
}),
|
||||
timeoutMs,
|
||||
"browser proxy request",
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isBrowserProxyTimeoutError(err)) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(
|
||||
formatBrowserProxyTimeoutMessage({
|
||||
method,
|
||||
path,
|
||||
profile: requestedProfile || resolved.defaultProfile || undefined,
|
||||
timeoutMs,
|
||||
wsBacked: isWsBackedBrowserProxyPath(path),
|
||||
status: null,
|
||||
}),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
body = stagedUpload.body;
|
||||
const remainingTimeoutMs = deadlineAt - Date.now();
|
||||
if (remainingTimeoutMs <= 0) {
|
||||
await discardStagedBrowserProxyUpload(stagedUpload);
|
||||
throw new Error(
|
||||
formatBrowserProxyTimeoutMessage({
|
||||
method,
|
||||
path,
|
||||
profile: requestedProfile || resolved.defaultProfile || undefined,
|
||||
timeoutMs,
|
||||
wsBacked: isWsBackedBrowserProxyPath(path),
|
||||
status: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await withTimeout(
|
||||
(signal) =>
|
||||
(timeoutSignal) =>
|
||||
dispatcher.dispatch({
|
||||
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
signal,
|
||||
signal: combineBrowserProxySignals(timeoutSignal, invocationSignal),
|
||||
}),
|
||||
timeoutMs,
|
||||
remainingTimeoutMs,
|
||||
"browser proxy request",
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -321,6 +401,9 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
await discardStagedBrowserProxyUpload(stagedUpload);
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
if (params.errorEnvelope === BROWSER_PROXY_ERROR_ENVELOPE) {
|
||||
// New callers opt into the closed envelope; older Gateways retain the
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/strin
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
NODE_BROWSER_PROXY_COMMAND,
|
||||
NODE_BROWSER_PROXY_COMMANDS,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FILE_COMMANDS,
|
||||
@@ -79,7 +79,7 @@ const SYSTEM_COMMANDS = [
|
||||
...NODE_EXEC_APPROVALS_COMMANDS,
|
||||
...NODE_FILE_COMMANDS,
|
||||
NODE_SYSTEM_NOTIFY_COMMAND,
|
||||
NODE_BROWSER_PROXY_COMMAND,
|
||||
...NODE_BROWSER_PROXY_COMMANDS,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
];
|
||||
@@ -87,7 +87,7 @@ const DESKTOP_HOST_COMMANDS = new Set<string>([
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
...NODE_EXEC_APPROVALS_COMMANDS,
|
||||
...NODE_FILE_COMMANDS,
|
||||
NODE_BROWSER_PROXY_COMMAND,
|
||||
...NODE_BROWSER_PROXY_COMMANDS,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
...SCREEN_COMMANDS,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -234,13 +234,20 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set<string> {
|
||||
"system.run.prepare",
|
||||
"system.which",
|
||||
"browser.proxy",
|
||||
"browser.proxy.upload.v1",
|
||||
"screen.snapshot",
|
||||
],
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
deviceFamily: "Linux",
|
||||
approvedCommands: ["system.run", "system.run.prepare", "system.which", "browser.proxy"],
|
||||
approvedCommands: [
|
||||
"system.run",
|
||||
"system.run.prepare",
|
||||
"system.which",
|
||||
"browser.proxy",
|
||||
"browser.proxy.upload.v1",
|
||||
],
|
||||
},
|
||||
{
|
||||
platform: "windows",
|
||||
@@ -250,6 +257,7 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set<string> {
|
||||
"system.run.prepare",
|
||||
"system.which",
|
||||
"browser.proxy",
|
||||
"browser.proxy.upload.v1",
|
||||
"screen.snapshot",
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user