Skip to content

Commit a196c0b

Browse files
LaPhilosophieeleqtrizit
authored andcommitted
fix(gateway): require admin scope for browser proxy invoke (openclaw#85916)
* fix(gateway): require admin scope for browser proxy invoke * fix(gateway): document trusted node scopes * fix(gateway): align browser scope test * fix(gateway): satisfy node test lint --------- Co-authored-by: Agustin Rivera <agustin@rivera-web.com>
1 parent a33f4f2 commit a196c0b

16 files changed

Lines changed: 508 additions & 14 deletions

docs/plugins/sdk-runtime.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ two-party event loops that do not go through the shared inbound reply runner.
257257

258258
Plugins that expose dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`. The policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls and higher-level plugin tools share the same enforcement path.
259259

260+
<Warning>
261+
The optional `scopes` field requests Gateway operator scopes for the invocation. OpenClaw honors it only for bundled plugins and trusted official plugin installations; requests from other plugins do not elevate the call. Use it only when a trusted plugin must invoke a node command with a stricter Gateway scope, such as `operator.admin`.
262+
</Warning>
263+
260264
</Accordion>
261265
<Accordion title="api.runtime.tasks.managedFlows">
262266
Bind a Task Flow runtime to an existing OpenClaw session key or trusted tool context, then create and manage Task Flows without passing an owner on every call.

extensions/browser/src/browser-tool.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ function nodeInvokeCall(callIndex: number): {
443443
body?: Record<string, unknown>;
444444
};
445445
};
446+
extra?: { scopes?: string[] };
446447
} {
447448
const toolName = mockCallArg<string>(gatewayMocks.callGatewayTool, callIndex, 0);
448449
const options = mockCallArg<{ timeoutMs?: number }>(gatewayMocks.callGatewayTool, callIndex, 1);
@@ -458,8 +459,13 @@ function nodeInvokeCall(callIndex: number): {
458459
body?: Record<string, unknown>;
459460
};
460461
}>(gatewayMocks.callGatewayTool, callIndex, 2);
462+
const extra = mockCallArg<{ scopes?: string[] } | undefined>(
463+
gatewayMocks.callGatewayTool,
464+
callIndex,
465+
3,
466+
);
461467
expect(toolName).toBe("node.invoke");
462-
return { options, request };
468+
return { options, request, extra };
463469
}
464470

465471
function lastNodeInvokeCall(): ReturnType<typeof nodeInvokeCall> {
@@ -747,6 +753,7 @@ describe("browser tool snapshot maxChars", () => {
747753
timeoutMs: 7777,
748754
}),
749755
}),
756+
{ scopes: ["operator.admin"] },
750757
);
751758
});
752759

@@ -855,8 +862,9 @@ describe("browser tool snapshot maxChars", () => {
855862
const tool = createBrowserTool();
856863
await tool.execute?.("call-1", { action: "status", target: "node" });
857864

858-
const { options, request } = lastNodeInvokeCall();
865+
const { options, request, extra } = lastNodeInvokeCall();
859866
expect(options.timeoutMs).toBe(25_000);
867+
expect(extra?.scopes).toEqual(["operator.admin"]);
860868
expect(request.nodeId).toBe("node-1");
861869
expect(request.command).toBe("browser.proxy");
862870
expect(request.params?.timeoutMs).toBe(20_000);

extensions/browser/src/browser-tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ async function callBrowserProxy(params: {
358358
},
359359
idempotencyKey: crypto.randomUUID(),
360360
},
361+
{ scopes: ["operator.admin"] },
361362
);
362363
const parsed = unwrapBrowserProxyPayload(payload);
363364
if (!parsed || typeof parsed !== "object" || !("result" in parsed)) {

extensions/google-meet/src/transports/chrome-browser-proxy.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe("Google Meet Chrome browser proxy", () => {
3636
timeoutMs: 100,
3737
},
3838
timeoutMs: 5_100,
39+
scopes: ["operator.admin"],
3940
});
4041
});
4142

extensions/google-meet/src/transports/chrome-browser-proxy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ export async function callBrowserProxyOnNode(params: {
192192
timeoutMs: params.timeoutMs,
193193
},
194194
timeoutMs: addTimerTimeoutGraceMs(params.timeoutMs) ?? 1,
195+
scopes: ["operator.admin"],
195196
});
196197
return parseBrowserProxyResult(raw);
197198
}

src/gateway/server-methods/nodes.invoke-wake.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ async function invokeNode(params: {
326326
error?: { code?: string; message?: string } | null;
327327
}>;
328328
};
329+
client?: unknown;
329330
requestParams?: Partial<Record<string, unknown>>;
330331
}) {
331332
const respond = vi.fn();
@@ -342,13 +343,32 @@ async function invokeNode(params: {
342343
logGateway,
343344
getRuntimeConfig: () => mocks.getRuntimeConfig(),
344345
} as never,
345-
client: null,
346+
client: (params.client ?? null) as never,
346347
req: { type: "req", id: "req-node-invoke", method: "node.invoke" },
347348
isWebchatConnect: () => false,
348349
});
349350
return respond;
350351
}
351352

353+
function createOperatorClient(params?: { scopes?: string[]; pluginRuntimeOwnerId?: string }) {
354+
return {
355+
connect: {
356+
role: "operator" as const,
357+
scopes: params?.scopes ?? ["operator.write"],
358+
client: {
359+
id: "operator-test",
360+
mode: "backend" as const,
361+
name: "operator-test",
362+
platform: "node",
363+
version: "test",
364+
},
365+
},
366+
internal: params?.pluginRuntimeOwnerId
367+
? { pluginRuntimeOwnerId: params.pluginRuntimeOwnerId }
368+
: {},
369+
};
370+
}
371+
352372
function createNodeClient(nodeId: string, commands?: string[]) {
353373
return {
354374
connect: {
@@ -557,6 +577,72 @@ describe("node.invoke APNs wake path", () => {
557577
vi.useRealTimers();
558578
});
559579

580+
it("rejects browser.proxy for plugin runtime owners without admin scope", async () => {
581+
const nodeRegistry = {
582+
get: vi.fn(() => ({
583+
nodeId: "browser-node",
584+
commands: ["browser.proxy"],
585+
})),
586+
invoke: vi.fn().mockResolvedValue({
587+
ok: true,
588+
payloadJSON: '{"ok":true}',
589+
}),
590+
};
591+
592+
const respond = await invokeNode({
593+
nodeRegistry,
594+
client: createOperatorClient({
595+
scopes: ["operator.write"],
596+
pluginRuntimeOwnerId: "third-party",
597+
}),
598+
requestParams: {
599+
nodeId: "browser-node",
600+
command: "browser.proxy",
601+
params: { method: "GET", path: "/profiles" },
602+
},
603+
});
604+
605+
const call = firstRespondCall(respond);
606+
expect(call[0]).toBe(false);
607+
expect(call[2]?.message).toContain("missing scope: operator.admin");
608+
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
609+
});
610+
611+
it("allows browser.proxy for admin-scoped plugin runtime callers", async () => {
612+
const nodeRegistry = {
613+
get: vi.fn(() => ({
614+
nodeId: "browser-node",
615+
commands: ["browser.proxy"],
616+
})),
617+
invoke: vi.fn().mockResolvedValue({
618+
ok: true,
619+
payloadJSON: '{"ok":true}',
620+
}),
621+
};
622+
623+
const respond = await invokeNode({
624+
nodeRegistry,
625+
client: createOperatorClient({
626+
scopes: ["operator.admin"],
627+
pluginRuntimeOwnerId: "google-meet",
628+
}),
629+
requestParams: {
630+
nodeId: "browser-node",
631+
command: "browser.proxy",
632+
params: { method: "GET", path: "/profiles" },
633+
},
634+
});
635+
636+
const call = firstRespondCall(respond);
637+
expect(call[0]).toBe(true);
638+
expect(nodeRegistry.invoke).toHaveBeenCalledTimes(1);
639+
expectRecordFields(mockArg(nodeRegistry.invoke, 0, 0), "node invoke payload", {
640+
nodeId: "browser-node",
641+
command: "browser.proxy",
642+
params: { method: "GET", path: "/profiles" },
643+
});
644+
});
645+
560646
it("keeps the existing not-connected response when wake path is unavailable", async () => {
561647
mocks.loadApnsRegistration.mockResolvedValue(null);
562648

src/gateway/server-methods/nodes.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ const TALK_PTT_COMMANDS = new Set([
111111
"talk.ptt.cancel",
112112
"talk.ptt.once",
113113
]);
114+
const BROWSER_PROXY_REQUIRED_SCOPE = "operator.admin";
114115
const talkPttEventSeqBySessionId = new Map<string, number>();
115116

116117
type NodeWakeNudgeAttempt = {
@@ -209,6 +210,11 @@ function isForbiddenBrowserProxyMutation(params: unknown): boolean {
209210
return Boolean(method && path && isPersistentBrowserProxyMutation(method, path));
210211
}
211212

213+
function clientHasOperatorAdminScope(client: GatewayClient | null): boolean {
214+
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
215+
return scopes.includes(BROWSER_PROXY_REQUIRED_SCOPE);
216+
}
217+
212218
function normalizePluginSurfaceRefreshParams(params: unknown): { surface: string } | undefined {
213219
if (!params || typeof params !== "object") {
214220
return undefined;
@@ -1322,7 +1328,14 @@ export const nodeHandlers: GatewayRequestHandlers = {
13221328
);
13231329
return;
13241330
}
1325-
1331+
if (command === "browser.proxy" && !clientHasOperatorAdminScope(client)) {
1332+
respond(
1333+
false,
1334+
undefined,
1335+
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${BROWSER_PROXY_REQUIRED_SCOPE}`),
1336+
);
1337+
return;
1338+
}
13261339
await respondUnavailableOnThrow(respond, async () => {
13271340
const cfg = context.getRuntimeConfig();
13281341
let nodeSession = context.nodeRegistry.get(nodeId);

src/gateway/server-plugins.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Gateway plugin tests cover plugin loading, auto-enable, runtime registry setup,
22
// request-scope injection, diagnostics, and handler dispatch integration.
33
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
4+
import { createPluginRecord } from "../plugins/loader-records.js";
45
import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js";
56
import type { PluginRegistry } from "../plugins/registry.js";
67
import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
@@ -116,6 +117,30 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
116117
diagnostics,
117118
});
118119

120+
function addLoadedPlugin(
121+
registry: PluginRegistry,
122+
params: {
123+
id: string;
124+
origin?: PluginRegistry["plugins"][number]["origin"];
125+
trustedOfficialInstall?: boolean;
126+
},
127+
): PluginRegistry {
128+
registry.plugins.push(
129+
createPluginRecord({
130+
id: params.id,
131+
name: params.id,
132+
source: `/tmp/${params.id}/index.js`,
133+
origin: params.origin ?? "bundled",
134+
enabled: true,
135+
configSchema: false,
136+
...(params.trustedOfficialInstall !== undefined
137+
? { trustedOfficialInstall: params.trustedOfficialInstall }
138+
: {}),
139+
}),
140+
);
141+
return registry;
142+
}
143+
119144
function createLookUpTableForTest(params: {
120145
manifestRegistry?: PluginLookUpTable["manifestRegistry"];
121146
pluginIds?: readonly string[];
@@ -899,6 +924,67 @@ describe("loadGatewayPlugins", () => {
899924
expect(result.nodes).toEqual([{ nodeId: "connected", connected: true }]);
900925
});
901926

927+
test("lets trusted official plugin runtime request admin scope for browser proxy", async () => {
928+
loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" }));
929+
loadGatewayStartupPluginsForTest();
930+
serverPluginsModule.setFallbackGatewayContext(createTestContext("nodes-invoke-browser-proxy"));
931+
932+
const runtime = runtimeModule.createPluginRuntime({
933+
allowGatewaySubagentBinding: true,
934+
});
935+
await gatewayRequestScopeModule.withPluginRuntimePluginScope(
936+
{ pluginId: "google-meet", pluginOrigin: "bundled" },
937+
() =>
938+
runtime.nodes.invoke({
939+
nodeId: "node-1",
940+
command: "browser.proxy",
941+
params: { method: "GET", path: "/profiles" },
942+
scopes: ["operator.admin"],
943+
}),
944+
);
945+
946+
expect(getLastDispatchedParams()).toMatchObject({
947+
nodeId: "node-1",
948+
command: "browser.proxy",
949+
params: { method: "GET", path: "/profiles" },
950+
});
951+
expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]);
952+
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet");
953+
});
954+
955+
test("does not let arbitrary plugin nodes runtime mint admin scope for browser proxy", async () => {
956+
loadOpenClawPlugins.mockReturnValue(
957+
addLoadedPlugin(createRegistry([]), { id: "third-party", origin: "global" }),
958+
);
959+
loadGatewayStartupPluginsForTest();
960+
serverPluginsModule.setFallbackGatewayContext(
961+
createTestContext("nodes-invoke-browser-proxy-no-elevate"),
962+
);
963+
964+
const runtime = runtimeModule.createPluginRuntime({
965+
allowGatewaySubagentBinding: true,
966+
});
967+
await gatewayRequestScopeModule.withPluginRuntimePluginScope(
968+
{ pluginId: "third-party", pluginOrigin: "global" },
969+
() =>
970+
runtime.nodes.invoke({
971+
nodeId: "node-1",
972+
command: "browser.proxy",
973+
params: { method: "GET", path: "/profiles" },
974+
scopes: ["operator.admin"],
975+
}),
976+
);
977+
978+
expect(getLastDispatchedParams()).toMatchObject({
979+
nodeId: "node-1",
980+
command: "browser.proxy",
981+
params: { method: "GET", path: "/profiles" },
982+
});
983+
expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]);
984+
expect(getLastDispatchedClientScopes()).not.toContain("operator.admin");
985+
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("third-party");
986+
});
987+
902988
test("forwards provider and model overrides when the request scope is authorized", async () => {
903989
const serverPlugins = serverPluginsModule;
904990
const runtime = await createSubagentRuntime(serverPlugins);

0 commit comments

Comments
 (0)