Skip to content

Commit ab422f9

Browse files
authored
Merge 9191955 into 444d54c
2 parents 444d54c + 9191955 commit ab422f9

8 files changed

Lines changed: 653 additions & 11 deletions

docs/gateway/operator-scopes.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,11 @@ stores relate.
9898
`node.pair.approve` derives extra required scopes from the pending request's
9999
command list:
100100

101-
| Declared commands | Required scopes |
102-
| ----------------------------------------------------- | ------------------------------------- |
103-
| none | `operator.pairing` |
104-
| non-exec node commands | `operator.pairing` + `operator.write` |
105-
| `system.run`, `system.run.prepare`, or `system.which` | `operator.pairing` + `operator.admin` |
101+
| Declared commands | Required scopes |
102+
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
103+
| none | `operator.pairing` |
104+
| non-exec node commands | `operator.pairing` + `operator.write` |
105+
| `system.run`, `system.run.prepare`, `system.which`, `system.execApprovals.get`, or `system.execApprovals.set` | `operator.pairing` + `operator.admin` |
106106

107107
Node pairing establishes identity and trust; it does not replace a node's own
108108
`system.run` exec approval policy.

src/gateway/node-command-policy.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/strin
55
import type { OpenClawConfig } from "../config/types.openclaw.js";
66
import {
77
NODE_BROWSER_PROXY_COMMAND,
8+
NODE_EXEC_APPROVALS_COMMANDS,
89
NODE_SYSTEM_NOTIFY_COMMAND,
910
NODE_SYSTEM_RUN_COMMANDS,
1011
} from "../infra/node-commands.js";
@@ -61,6 +62,7 @@ const DESKTOP_HOST_COMMANDS = new Set<string>([
6162
...NODE_SYSTEM_RUN_COMMANDS,
6263
NODE_BROWSER_PROXY_COMMAND,
6364
...SCREEN_COMMANDS,
65+
...NODE_EXEC_APPROVALS_COMMANDS,
6466
]);
6567
const UNKNOWN_PLATFORM_COMMANDS = [
6668
...CAMERA_COMMANDS,
@@ -115,14 +117,16 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
115117
...MOTION_COMMANDS,
116118
...SYSTEM_COMMANDS,
117119
...SCREEN_COMMANDS,
120+
...NODE_EXEC_APPROVALS_COMMANDS,
118121
],
119-
linux: [...SYSTEM_COMMANDS],
122+
linux: [...SYSTEM_COMMANDS, ...NODE_EXEC_APPROVALS_COMMANDS],
120123
windows: [
121124
...CAMERA_COMMANDS,
122125
...LOCATION_COMMANDS,
123126
...DEVICE_COMMANDS,
124127
...SYSTEM_COMMANDS,
125128
...SCREEN_COMMANDS,
129+
...NODE_EXEC_APPROVALS_COMMANDS,
126130
],
127131
// Fail-safe: unknown metadata should not receive host exec defaults.
128132
unknown: [...UNKNOWN_PLATFORM_COMMANDS],
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/**
2+
* Integration test for exec-approvals node preflight guard — real gateway server.
3+
*
4+
* Starts a real Gateway WebSocket server and verifies the exec-approvals node
5+
* handlers return structured errors for unknown nodes (fallthrough to invoke
6+
* error mapping), connected nodes that lack the required command in their
7+
* approved command surface, and pre-paired capable nodes whose effective
8+
* command surface includes exec-approvals.
9+
*
10+
* Prints full RPC responses for PR evidence collection.
11+
*/
12+
import os from "node:os";
13+
import path from "node:path";
14+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
15+
import type { DeviceIdentity } from "../../infra/device-identity.js";
16+
import { loadOrCreateDeviceIdentity } from "../../infra/device-identity.js";
17+
import { approveNodePairing, requestNodePairing } from "../../infra/node-pairing.js";
18+
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
19+
import { connectGatewayClient, disconnectGatewayClient } from "../test-helpers.e2e.js";
20+
import {
21+
connectOk,
22+
installGatewayTestHooks,
23+
rpcReq,
24+
startServerWithClient,
25+
} from "../test-helpers.js";
26+
import { acknowledgeNodeInvokeRequestForTest } from "../test-helpers.node-invoke.js";
27+
28+
installGatewayTestHooks({ scope: "suite" });
29+
30+
/** Shape of a node entry in node.list RPC response. */
31+
interface NodeListEntry {
32+
nodeId: string;
33+
displayName?: string;
34+
connected?: boolean;
35+
}
36+
37+
describe("exec-approvals node preflight (real gateway)", () => {
38+
let gateway: Awaited<ReturnType<typeof startServerWithClient>>;
39+
let limitedNode: Awaited<ReturnType<typeof connectGatewayClient>>;
40+
let capableNode: Awaited<ReturnType<typeof connectGatewayClient>>;
41+
let naiveCapableNode: Awaited<ReturnType<typeof connectGatewayClient>>;
42+
let limitedNodeId: string;
43+
let capableNodeId: string;
44+
let naiveCapableNodeId: string;
45+
let capableIdent: DeviceIdentity;
46+
47+
beforeAll(async () => {
48+
gateway = await startServerWithClient("secret");
49+
await connectOk(gateway.ws);
50+
51+
// Pre-create distinct device identities so the two node connections don't collide.
52+
const limitedIdent = loadOrCreateDeviceIdentity(
53+
path.join(
54+
process.env.OPENCLAW_STATE_DIR ?? os.tmpdir(),
55+
"test-device-identities",
56+
"exec-approvals-limited-node.json",
57+
),
58+
);
59+
capableIdent = loadOrCreateDeviceIdentity(
60+
path.join(
61+
process.env.OPENCLAW_STATE_DIR ?? os.tmpdir(),
62+
"test-device-identities",
63+
"exec-approvals-capable-node.json",
64+
),
65+
);
66+
67+
// Pre-pair the capable node so its approved command surface includes
68+
// exec-approvals commands. This simulates a previously paired desktop
69+
// node that already had exec-approvals in its pairing allowlist.
70+
// operator.admin is used here as a superset of the required write scope
71+
// to ensure the pre-pairing succeeds regardless of the current pairing
72+
// authz defaults.
73+
const prePairRequest = await requestNodePairing({
74+
nodeId: capableIdent.deviceId,
75+
displayName: "capable-node",
76+
platform: "linux",
77+
deviceFamily: "Linux",
78+
commands: [
79+
"system.run",
80+
"system.notify",
81+
"browser.proxy",
82+
"system.execApprovals.get",
83+
"system.execApprovals.set",
84+
],
85+
});
86+
const approved = await approveNodePairing(prePairRequest.request.requestId, {
87+
callerScopes: ["operator.admin"],
88+
});
89+
if (!approved || "status" in approved) {
90+
throw new Error(`Failed to pre-pair capable node: ${JSON.stringify(approved)}`);
91+
}
92+
93+
// Connect a node that does NOT advertise exec-approvals commands.
94+
limitedNode = await connectGatewayClient({
95+
url: `ws://127.0.0.1:${gateway.port}`,
96+
token: "secret",
97+
role: "node",
98+
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
99+
clientDisplayName: "limited-node",
100+
clientVersion: "1.0.0",
101+
platform: "linux",
102+
deviceFamily: "Linux",
103+
mode: GATEWAY_CLIENT_MODES.NODE,
104+
scopes: [],
105+
commands: ["system.run", "system.notify", "browser.proxy"],
106+
deviceIdentity: limitedIdent,
107+
timeoutMessage: "timeout waiting for limited-node to connect",
108+
});
109+
110+
// Connect the pre-paired node that DOES advertise exec-approvals commands.
111+
// Because it is already paired, reconcileNodePairingOnConnect will find the
112+
// paired entry and set effectiveCommands to include exec-approvals.
113+
capableNode = await connectGatewayClient({
114+
url: `ws://127.0.0.1:${gateway.port}`,
115+
token: "secret",
116+
role: "node",
117+
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
118+
clientDisplayName: "capable-node",
119+
clientVersion: "1.0.0",
120+
platform: "linux",
121+
deviceFamily: "Linux",
122+
mode: GATEWAY_CLIENT_MODES.NODE,
123+
scopes: [],
124+
commands: [
125+
"system.run",
126+
"system.notify",
127+
"browser.proxy",
128+
"system.execApprovals.get",
129+
"system.execApprovals.set",
130+
],
131+
deviceIdentity: capableIdent,
132+
timeoutMessage: "timeout waiting for capable-node to connect",
133+
onEvent: (evt) => {
134+
if (capableNode) {
135+
acknowledgeNodeInvokeRequestForTest({
136+
client: capableNode,
137+
event: evt,
138+
onInvoke: () => {},
139+
});
140+
}
141+
},
142+
});
143+
144+
// Connect a node that declares exec-approvals commands WITHOUT
145+
// pre-pairing. reconcileNodePairingOnConnect returns effectiveCommands:[]
146+
// (first-connect pending pairing). The preflight gate should reject
147+
// exec-approvals RPCs because the effective command surface is empty —
148+
// raw declared capability is not sufficient to bypass pairing approval.
149+
naiveCapableNode = await connectGatewayClient({
150+
url: `ws://127.0.0.1:${gateway.port}`,
151+
token: "secret",
152+
role: "node",
153+
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
154+
clientDisplayName: "naive-capable-node",
155+
clientVersion: "1.0.0",
156+
platform: "linux",
157+
deviceFamily: "Linux",
158+
mode: GATEWAY_CLIENT_MODES.NODE,
159+
scopes: [],
160+
commands: [
161+
"system.run",
162+
"system.notify",
163+
"browser.proxy",
164+
"system.execApprovals.get",
165+
"system.execApprovals.set",
166+
],
167+
deviceIdentity: loadOrCreateDeviceIdentity(
168+
path.join(
169+
process.env.OPENCLAW_STATE_DIR ?? os.tmpdir(),
170+
"test-device-identities",
171+
"exec-approvals-naive-capable.json",
172+
),
173+
),
174+
timeoutMessage: "timeout waiting for naive-capable-node to connect",
175+
});
176+
177+
// Look up connected node IDs from the operator client.
178+
const listRes = await rpcReq<{ nodes?: NodeListEntry[] }>(gateway.ws, "node.list", {});
179+
const nodes = listRes.payload?.nodes ?? [];
180+
const limited = nodes.find((n) => n.displayName === "limited-node");
181+
const capable = nodes.find((n) => n.displayName === "capable-node");
182+
const naive = nodes.find((n) => n.displayName === "naive-capable-node");
183+
if (!limited?.nodeId || !capable?.nodeId || !naive?.nodeId) {
184+
throw new Error(
185+
`Failed to find connected nodes in node.list: ` +
186+
`limited=${limited?.nodeId}, capable=${capable?.nodeId}, naive=${naive?.nodeId}`,
187+
);
188+
}
189+
limitedNodeId = limited.nodeId;
190+
capableNodeId = capable.nodeId;
191+
naiveCapableNodeId = naive.nodeId;
192+
}, 60_000);
193+
194+
afterAll(async () => {
195+
await Promise.all([
196+
disconnectGatewayClient(limitedNode),
197+
disconnectGatewayClient(capableNode),
198+
disconnectGatewayClient(naiveCapableNode),
199+
]);
200+
gateway?.ws.close();
201+
await gateway?.server.close();
202+
}, 30_000);
203+
204+
// ---- pre-paired capable-node scenario ----
205+
206+
test("pre-paired capable node connects and appears in node.list", () => {
207+
// The capable node was pre-paired via approveNodePairing with
208+
// operator.admin scope (superset of the required write scope).
209+
// The node connected successfully and reconcileNodePairingOnConnect
210+
// found the paired entry, so effectiveCommands include exec-approvals.
211+
expect(capableNodeId).toBeTruthy();
212+
// The limited node also connects but without pre-pairing.
213+
expect(limitedNodeId).toBeTruthy();
214+
expect(limitedNodeId).not.toBe(capableNodeId);
215+
});
216+
217+
test("pre-paired capable node succeeds on exec.approvals.node.get", async () => {
218+
const res = await rpcReq<{ hash?: string; file?: unknown }>(
219+
gateway.ws,
220+
"exec.approvals.node.get",
221+
{ nodeId: capableNodeId },
222+
);
223+
224+
console.log(JSON.stringify(res, null, 2));
225+
226+
expect(res.ok).toBe(true);
227+
expect(res.payload).toBeDefined();
228+
// The executing node returns the current approvals file shape for
229+
// a freshly paired node — a valid response proves the preflight
230+
// allowlist includes system.execApprovals.get for declared-capable
231+
// desktop nodes.
232+
});
233+
234+
test("pre-paired capable node succeeds on exec.approvals.node.set with empty file", async () => {
235+
const res = await rpcReq<{ hash?: string }>(gateway.ws, "exec.approvals.node.set", {
236+
nodeId: capableNodeId,
237+
file: { version: 1, agents: {} },
238+
});
239+
240+
console.log(JSON.stringify(res, null, 2));
241+
242+
// set succeeds because the capable node declares system.execApprovals.set
243+
// in its effective command surface and the preflight allowlist includes it.
244+
expect(res.ok).toBe(true);
245+
});
246+
247+
// ---- naive-capable-node scenario (no pre-pairing) ----
248+
249+
test("naive first-connect node is rejected on exec.approvals.node.get when not yet paired", async () => {
250+
// The node connected without pre-pairing, so effectiveCommands are empty
251+
// (pending pairing). The preflight gate rejects exec-approvals RPCs
252+
// because the effective command surface is empty — raw declaredCommands
253+
// alone is not sufficient to bypass pairing approval.
254+
const res = await rpcReq<{ hash?: string; file?: unknown }>(
255+
gateway.ws,
256+
"exec.approvals.node.get",
257+
{ nodeId: naiveCapableNodeId },
258+
);
259+
260+
console.log(JSON.stringify(res, null, 2));
261+
262+
expect(res.ok).toBe(false);
263+
expect(res.error).toBeDefined();
264+
expect(res.error!.code).toBe("INVALID_REQUEST");
265+
expect(res.error!.message).toContain("does not allow");
266+
});
267+
268+
// ---- unknown-node scenarios ----
269+
270+
test("unknown node falls through to invoke and returns UNAVAILABLE with nodeError", async () => {
271+
const res = await rpcReq(gateway.ws, "exec.approvals.node.get", {
272+
nodeId: "node-that-does-not-exist",
273+
});
274+
275+
console.log(JSON.stringify(res, null, 2));
276+
277+
expect(res.ok).toBe(false);
278+
expect(res.error).toBeDefined();
279+
expect(res.error!.code).toBe("UNAVAILABLE");
280+
expect(res.error!.message).toContain("NOT_CONNECTED");
281+
expect(res.error!.details).toBeDefined();
282+
const details = res.error!.details as Record<string, unknown> | undefined;
283+
expect(details?.nodeError).toBeDefined();
284+
expect((details?.nodeError as Record<string, unknown>)?.code).toBe("NOT_CONNECTED");
285+
});
286+
287+
test("unknown node returns UNAVAILABLE for exec.approvals.node.set", async () => {
288+
const res = await rpcReq(gateway.ws, "exec.approvals.node.set", {
289+
nodeId: "ghost-node",
290+
file: { version: 1, agents: {} },
291+
baseHash: "abc123",
292+
});
293+
294+
console.log(JSON.stringify(res, null, 2));
295+
296+
expect(res.ok).toBe(false);
297+
expect(res.error).toBeDefined();
298+
expect(res.error!.code).toBe("UNAVAILABLE");
299+
expect(res.error!.details).toBeDefined();
300+
const details = res.error!.details as Record<string, unknown> | undefined;
301+
expect(details?.nodeError).toBeDefined();
302+
});
303+
304+
// ---- connected-node unsupported scenarios ----
305+
306+
test("connected node without exec-approvals.get returns INVALID_REQUEST", async () => {
307+
const res = await rpcReq(gateway.ws, "exec.approvals.node.get", {
308+
nodeId: limitedNodeId,
309+
});
310+
311+
console.log(JSON.stringify(res, null, 2));
312+
313+
expect(res.ok).toBe(false);
314+
expect(res.error).toBeDefined();
315+
expect(res.error!.code).toBe("INVALID_REQUEST");
316+
expect(res.error!.message).toContain("does not support system.execApprovals.get");
317+
expect(res.error!.details).toBeDefined();
318+
const details = res.error!.details as Record<string, unknown> | undefined;
319+
// First-connect nodes have empty effective commands until pairing approved.
320+
expect(details?.requestedCommand).toBe("system.execApprovals.get");
321+
});
322+
323+
test("connected node without exec-approvals.set returns INVALID_REQUEST", async () => {
324+
const res = await rpcReq(gateway.ws, "exec.approvals.node.set", {
325+
nodeId: limitedNodeId,
326+
file: { version: 1, agents: {} },
327+
baseHash: "abc123",
328+
});
329+
330+
console.log(JSON.stringify(res, null, 2));
331+
332+
expect(res.ok).toBe(false);
333+
expect(res.error).toBeDefined();
334+
expect(res.error!.code).toBe("INVALID_REQUEST");
335+
expect(res.error!.message).toContain("does not support system.execApprovals.set");
336+
});
337+
338+
// ---- local regression ----
339+
340+
test("gateway responds correctly for exec.approvals.get (local, no node target)", async () => {
341+
const res = await rpcReq(gateway.ws, "exec.approvals.get", {});
342+
343+
console.log(JSON.stringify(res, null, 2));
344+
345+
expect(res.ok).toBe(true);
346+
expect(res.payload).toBeDefined();
347+
});
348+
});

0 commit comments

Comments
 (0)