From 6160e7a411ec9c39f4539514174e99055b7024d8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 14:31:21 +0100 Subject: [PATCH] fix(gateway): hide unapproved node surfaces Co-authored-by: samzong --- CHANGELOG.md | 1 + src/gateway/node-catalog.test.ts | 4 + src/gateway/node-connect-reconcile.test.ts | 40 +++++ src/gateway/node-connect-reconcile.ts | 18 +- src/gateway/node-invoke-plugin-policy.test.ts | 2 + src/gateway/node-registry.test.ts | 69 +++++++- src/gateway/node-registry.ts | 80 +++++++++ src/gateway/server-methods/nodes.ts | 61 +++++++ src/gateway/server-talk-nodes.test.ts | 2 + .../server.roles-allowlist-update.test.ts | 155 +++++++++++++++++- .../server/ws-connection/message-handler.ts | 8 + 11 files changed, 433 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 284203ad570..e2a6ac75bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway: hide pending Node pairing commands, capabilities, and permissions until approval, and refresh the live approved surface when pairings change. (#80741) Thanks @samzong. - Plugins/Feishu/WhatsApp/Line: enforce inbound media size caps while reading download streams, avoiding full buffering of oversized attachments. (#81044, #81050) Thanks @samzong. - Config: serialize and retry semantic config mutations centrally, so concurrent commands can rebase safe changes instead of clobbering or hand-rolling command-local retry loops. (#76601) - Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987. diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index f88c9074ba6..47067a899cb 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -106,7 +106,9 @@ describe("gateway/node-catalog", () => { displayName: "Mac", platform: "darwin", version: "1.2.3", + declaredCaps: ["camera", "screen"], caps: ["camera", "screen"], + declaredCommands: ["screen.snapshot", "system.run"], commands: ["screen.snapshot", "system.run"], remoteIp: "100.0.0.11", pathEnv: "/usr/bin:/bin", @@ -257,7 +259,9 @@ describe("gateway/node-catalog", () => { client: {} as never, displayName: "Mac", platform: "darwin", + declaredCaps: ["canvas"], caps: ["canvas"], + declaredCommands: ["canvas.snapshot"], commands: ["canvas.snapshot"], connectedAtMs: 1, }, diff --git a/src/gateway/node-connect-reconcile.test.ts b/src/gateway/node-connect-reconcile.test.ts index 92f8e8a8631..aef949544bf 100644 --- a/src/gateway/node-connect-reconcile.test.ts +++ b/src/gateway/node-connect-reconcile.test.ts @@ -59,6 +59,45 @@ describe("reconcileNodePairingOnConnect", () => { }); }); + it("keeps first-time pending node surfaces declared but not effective", async () => { + const requestPairing = vi.fn(async (input: NodePairingRequestInput) => ({ + status: "pending" as const, + request: { ...input, requestId: "req-pending", ts: 1 }, + created: true, + })); + + const result = await reconcileNodePairingOnConnect({ + cfg: {} as never, + connectParams: makeNodeConnectParams({ + client: { + id: "openclaw-macos", + version: "test", + platform: "darwin", + mode: "node", + }, + caps: ["talk"], + commands: ["system.run"], + permissions: { camera: true }, + }), + pairedNode: null, + requestPairing, + }); + + expect(result.declaredCaps).toEqual(["talk"]); + expect(result.effectiveCaps).toEqual([]); + expect(result.declaredCommands).toEqual(["system.run"]); + expect(result.effectiveCommands).toEqual([]); + expect(result.declaredPermissions).toEqual({ camera: true }); + expect(result.effectivePermissions).toBeUndefined(); + expect(requestPairing).toHaveBeenCalledWith( + expect.objectContaining({ + caps: ["talk"], + commands: ["system.run"], + permissions: { camera: true }, + }), + ); + }); + it("requires a fresh pairing request when paired node capabilities change", async () => { const requestPairing = vi.fn(async (input: NodePairingRequestInput) => ({ status: "pending" as const, @@ -93,6 +132,7 @@ describe("reconcileNodePairingOnConnect", () => { }); expect(result.effectiveCaps).toEqual(["camera"]); expect(result.effectiveCommands).toEqual([]); + expect(result.declaredCaps).toEqual(["camera", "screen"]); expect(result.pendingPairing?.request.requestId).toBe("req-caps"); }); diff --git a/src/gateway/node-connect-reconcile.ts b/src/gateway/node-connect-reconcile.ts index 58f2800ff4e..e8af0c162b2 100644 --- a/src/gateway/node-connect-reconcile.ts +++ b/src/gateway/node-connect-reconcile.ts @@ -13,8 +13,11 @@ import type { ConnectParams } from "./protocol/index.js"; export type NodeConnectPairingReconcileResult = { nodeId: string; + declaredCaps: string[]; effectiveCaps: string[]; + declaredCommands: string[]; effectiveCommands: string[]; + declaredPermissions?: Record; effectivePermissions?: Record; pendingPairing?: RequestNodePairingResult; }; @@ -169,9 +172,12 @@ export async function reconcileNodePairingOnConnect(params: { ); return { nodeId, - effectiveCaps: declaredCaps, - effectiveCommands: declared, - effectivePermissions: declaredPermissions, + declaredCaps, + effectiveCaps: [], + declaredCommands: declared, + effectiveCommands: [], + declaredPermissions, + effectivePermissions: undefined, pendingPairing, }; } @@ -211,8 +217,11 @@ export async function reconcileNodePairingOnConnect(params: { ); return { nodeId, + declaredCaps, effectiveCaps: effectiveApprovedDeclaredCaps, + declaredCommands: declared, effectiveCommands: effectiveApprovedDeclaredCommands, + declaredPermissions, effectivePermissions: effectiveApprovedDeclaredPermissions, pendingPairing, }; @@ -220,8 +229,11 @@ export async function reconcileNodePairingOnConnect(params: { return { nodeId, + declaredCaps, effectiveCaps: declaredCaps, + declaredCommands: declared, effectiveCommands: declared, + declaredPermissions, effectivePermissions: declaredPermissions, }; } diff --git a/src/gateway/node-invoke-plugin-policy.test.ts b/src/gateway/node-invoke-plugin-policy.test.ts index fe8814a1b95..57b92712f7d 100644 --- a/src/gateway/node-invoke-plugin-policy.test.ts +++ b/src/gateway/node-invoke-plugin-policy.test.ts @@ -18,7 +18,9 @@ function createNodeSession(): NodeSession { nodeId: "node-1", connId: "conn-1", client: {} as NodeSession["client"], + declaredCaps: [], caps: [], + declaredCommands: ["demo.read"], commands: ["demo.read"], connectedAtMs: 0, }; diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index 913ccde6dd1..5126b990aad 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -6,7 +6,17 @@ function makeClient( connId: string, nodeId: string, sent: string[] = [], - opts: { clientId?: string; platform?: string; version?: string } = {}, + opts: { + clientId?: string; + platform?: string; + version?: string; + caps?: string[]; + commands?: string[]; + permissions?: Record; + declaredCaps?: string[]; + declaredCommands?: string[]; + declaredPermissions?: Record; + } = {}, ): GatewayWsClient { return { connId, @@ -19,6 +29,8 @@ function makeClient( }, } as unknown as GatewayWsClient["socket"], connect: { + minProtocol: 1, + maxProtocol: 1, client: { id: opts.clientId ?? "openclaw-macos", version: opts.version ?? "1.0.0", @@ -32,7 +44,13 @@ function makeClient( signedAt: 1, nonce: "nonce", }, - } as GatewayWsClient["connect"], + caps: opts.caps ?? [], + commands: opts.commands ?? [], + permissions: opts.permissions, + declaredCaps: opts.declaredCaps, + declaredCommands: opts.declaredCommands, + declaredPermissions: opts.declaredPermissions, + } as unknown as GatewayWsClient["connect"], }; } @@ -433,4 +451,51 @@ describe("gateway/node-registry", () => { '{"type":"event","event":"heartbeat"}', ]); }); + + it("refreshes effective live surface within the declared surface", () => { + const registry = new NodeRegistry(); + const client = makeClient("conn-1", "node-1", [], { + caps: [], + commands: [], + declaredCaps: ["talk"], + declaredCommands: ["talk.ptt.start"], + declaredPermissions: { microphone: true, camera: false }, + }); + + const session = registry.register(client, {}); + expect(session.caps).toEqual([]); + expect(session.commands).toEqual([]); + + const updated = registry.updateSurface("node-1", { + caps: ["talk", "screen"], + commands: ["talk.ptt.start", "system.run"], + permissions: { microphone: true, camera: true }, + }); + + expect(updated?.caps).toEqual(["talk"]); + expect(updated?.commands).toEqual(["talk.ptt.start"]); + expect(updated?.permissions).toEqual({ microphone: true, camera: false }); + expect(client.connect.caps).toEqual(["talk"]); + expect((client.connect as { commands?: string[] }).commands).toEqual(["talk.ptt.start"]); + }); + + it("clears effective permissions when explicitly removed", () => { + const registry = new NodeRegistry(); + const client = makeClient("conn-1", "node-1", [], { + permissions: { camera: false }, + declaredPermissions: { camera: false }, + }); + + registry.register(client, {}); + const updated = registry.updateSurface("node-1", { + caps: [], + commands: [], + permissions: undefined, + }); + + expect(updated?.permissions).toBeUndefined(); + expect( + (client.connect as { permissions?: Record }).permissions, + ).toBeUndefined(); + }); }); diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 46707594968..c47593d3845 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -15,8 +15,11 @@ export type NodeSession = { deviceFamily?: string; modelIdentifier?: string; remoteIp?: string; + declaredCaps: string[]; caps: string[]; + declaredCommands: string[]; commands: string[]; + declaredPermissions?: Record; permissions?: Record; pathEnv?: string; connectedAtMs: number; @@ -138,13 +141,27 @@ export class NodeRegistry { const connect = client.connect; const nodeId = connect.device?.id ?? connect.client.id; const caps = Array.isArray(connect.caps) ? connect.caps : []; + const declaredCaps = Array.isArray((connect as { declaredCaps?: string[] }).declaredCaps) + ? ((connect as { declaredCaps?: string[] }).declaredCaps ?? []) + : caps; const commands = Array.isArray((connect as { commands?: string[] }).commands) ? ((connect as { commands?: string[] }).commands ?? []) : []; + const declaredCommands = Array.isArray( + (connect as { declaredCommands?: string[] }).declaredCommands, + ) + ? ((connect as { declaredCommands?: string[] }).declaredCommands ?? []) + : commands; const permissions = typeof (connect as { permissions?: Record }).permissions === "object" ? ((connect as { permissions?: Record }).permissions ?? undefined) : undefined; + const declaredPermissions = + typeof (connect as { declaredPermissions?: Record }).declaredPermissions === + "object" + ? ((connect as { declaredPermissions?: Record }).declaredPermissions ?? + undefined) + : permissions; const pathEnv = typeof (connect as { pathEnv?: string }).pathEnv === "string" ? (connect as { pathEnv?: string }).pathEnv @@ -163,8 +180,11 @@ export class NodeRegistry { deviceFamily: connect.client.deviceFamily, modelIdentifier: connect.client.modelIdentifier, remoteIp: opts.remoteIp, + declaredCaps, caps, + declaredCommands, commands, + declaredPermissions, permissions, pathEnv, connectedAtMs: Date.now(), @@ -208,6 +228,66 @@ export class NodeRegistry { return this.nodesById.get(nodeId); } + updateCommands(nodeId: string, commands: readonly string[]): NodeSession | null { + return this.updateSurface(nodeId, { commands }); + } + + updateSurface( + nodeId: string, + surface: { + caps?: readonly string[]; + commands: readonly string[]; + permissions?: Record | undefined; + }, + ): NodeSession | null { + const node = this.nodesById.get(nodeId); + if (!node) { + return null; + } + + const declaredCommands = new Set(node.declaredCommands); + const nextCommands = surface.commands.filter((command) => declaredCommands.has(command)); + node.commands = nextCommands; + (node.client.connect as { commands?: string[] }).commands = nextCommands; + + if ("caps" in surface) { + const declaredCaps = new Set(node.declaredCaps); + const nextCaps = (surface.caps ?? []).filter((capability) => declaredCaps.has(capability)); + node.caps = nextCaps; + (node.client.connect as { caps?: string[] }).caps = nextCaps; + } + + if ("permissions" in surface) { + if (surface.permissions === undefined) { + node.permissions = undefined; + (node.client.connect as { permissions?: Record }).permissions = undefined; + return node; + } + const declared = node.declaredPermissions ?? {}; + const nextEntries: Array<[string, boolean]> = []; + for (const [key, declaredValue] of Object.entries(declared)) { + if (!declaredValue) { + nextEntries.push([key, false]); + continue; + } + const approvedValue = surface.permissions?.[key]; + if (approvedValue === true) { + nextEntries.push([key, true]); + continue; + } + if (approvedValue === false) { + nextEntries.push([key, false]); + } + } + const nextPermissions = nextEntries.length > 0 ? Object.fromEntries(nextEntries) : undefined; + node.permissions = nextPermissions; + (node.client.connect as { permissions?: Record }).permissions = + nextPermissions; + } + + return node; + } + async invoke(params: { nodeId: string; command: string; diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index ddd1774c493..675dd1da3d0 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -21,6 +21,11 @@ import { resolveApnsAuthConfigFromEnv, resolveApnsRelayConfigFromEnv, } from "../../infra/push-apns.js"; +import { + recordRemoteNodeInfo, + refreshRemoteNodeBins, + removeRemoteNodeInfo, +} from "../../infra/skills-remote.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -29,10 +34,12 @@ import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-ca import { isForegroundRestrictedPluginNodeCommand, isNodeCommandAllowed, + normalizeDeclaredNodeCommands, resolveNodeCommandAllowlist, } from "../node-command-policy.js"; import { applyPluginNodeInvokePolicy } from "../node-invoke-plugin-policy.js"; import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js"; +import type { NodeSession } from "../node-registry.js"; import { refreshClientPluginNodeCapability } from "../plugin-node-capability.js"; import { type ConnectParams, @@ -295,6 +302,34 @@ function listPendingNodeActions(nodeId: string): PendingNodeAction[] { return prunePendingNodeActions(nodeId, Date.now()); } +function refreshConnectedNodeSurfaceCaches(params: { + context: GatewayRequestContext; + nodeSession: NodeSession; + cfg?: OpenClawConfig; +}) { + const cfg = params.cfg ?? params.context.getRuntimeConfig(); + const { nodeSession } = params; + recordRemoteNodeInfo({ + nodeId: nodeSession.nodeId, + displayName: nodeSession.displayName, + platform: nodeSession.platform, + deviceFamily: nodeSession.deviceFamily, + commands: nodeSession.commands, + remoteIp: nodeSession.remoteIp, + }); + void refreshRemoteNodeBins({ + nodeId: nodeSession.nodeId, + platform: nodeSession.platform, + deviceFamily: nodeSession.deviceFamily, + commands: nodeSession.commands, + cfg, + }).catch((err) => + params.context.logGateway.warn( + `remote bin probe failed for ${nodeSession.nodeId}: ${formatErrorMessage(err)}`, + ), + ); +} + function resolveAllowedPendingNodeActions(params: { nodeId: string; client: { connect?: ConnectParams | null } | null; @@ -734,6 +769,25 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } const approvedNode = approved.node; + const cfg = context.getRuntimeConfig(); + const currentAllowlist = resolveNodeCommandAllowlist(cfg, { + platform: approvedNode.platform, + deviceFamily: approvedNode.deviceFamily, + caps: approvedNode.caps, + commands: approvedNode.commands, + }); + const currentAllowedCommands = normalizeDeclaredNodeCommands({ + declaredCommands: approvedNode.commands ?? [], + allowlist: currentAllowlist, + }); + const updatedNode = context.nodeRegistry.updateSurface(approvedNode.nodeId, { + caps: approvedNode.caps ?? [], + commands: currentAllowedCommands, + permissions: approvedNode.permissions, + }); + if (updatedNode) { + refreshConnectedNodeSurfaceCaches({ context, nodeSession: updatedNode, cfg }); + } context.broadcast( "node.pair.resolved", { @@ -792,6 +846,13 @@ export const nodeHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId")); return; } + pendingNodeActionsById.delete(removed.nodeId); + context.nodeRegistry.updateSurface(removed.nodeId, { + caps: [], + commands: [], + permissions: undefined, + }); + removeRemoteNodeInfo(removed.nodeId); context.broadcast( "node.pair.resolved", { diff --git a/src/gateway/server-talk-nodes.test.ts b/src/gateway/server-talk-nodes.test.ts index 36d7d210571..752eb12b0d5 100644 --- a/src/gateway/server-talk-nodes.test.ts +++ b/src/gateway/server-talk-nodes.test.ts @@ -8,7 +8,9 @@ function registryWith(nodes: Array>): NodeRegistry { nodes.map((node, index) => ({ nodeId: `node-${index}`, connId: `conn-${index}`, + declaredCaps: [], caps: [], + declaredCommands: [], commands: [], connectedAtMs: 0, ...node, diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index b541b8d76c1..c4e7caa3e79 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -215,6 +215,14 @@ async function expectPendingPairingCommands(nodeId: string, commands: string[]) expect(pending?.commands).toEqual(commands); } +async function getPendingNodePairing(nodeId: string) { + const pairingList = await rpcReq<{ + pending?: Array<{ requestId?: string; nodeId?: string; commands?: string[] }>; + }>(ws, "node.pair.list", {}); + expect(pairingList.ok).toBe(true); + return (pairingList.payload?.pending ?? []).find((entry) => entry.nodeId === nodeId); +} + describe("gateway role enforcement", () => { test("enforces operator and node permissions", async () => { let nodeClient: GatewayClient | undefined; @@ -463,7 +471,7 @@ describe("gateway node command allowlist", () => { } }); - test("keeps allowlisted declared commands available before node pairing exists", async () => { + test("hides allowlisted declared commands before node pairing is approved", async () => { const displayName = "node-device-paired-only"; let nodeClient: GatewayClient | undefined; @@ -481,17 +489,160 @@ describe("gateway node command allowlist", () => { const node = await findConnectedNodeByDisplayName(displayName); return node?.commands?.toSorted() ?? []; }, FAST_WAIT_OPTS) - .toEqual(["canvas.snapshot", "system.run"]); + .toEqual([]); const node = await findConnectedNodeByDisplayName(displayName); const nodeId = requireNodeId(node?.nodeId, displayName); await expectPendingPairingCommands(nodeId, ["canvas.snapshot", "system.run"]); + + const canvasRes = await rpcReq(ws, "node.invoke", { + nodeId, + command: "canvas.snapshot", + params: { format: "png" }, + idempotencyKey: "pending-node-canvas", + }); + expect(canvasRes.ok).toBe(false); + expect(canvasRes.error?.message ?? "").toContain("node command not allowed"); } finally { await nodeClient?.stopAndWait(); } }); + test("refreshes live commands when pending node pairing is approved", async () => { + const displayName = "node-approve-live-commands"; + let nodeClient: GatewayClient | undefined; + let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null; + const waitForInvoke = () => + new Promise<{ id?: string; nodeId?: string }>((resolve) => { + resolveInvoke = resolve; + }); + + try { + nodeClient = await connectNodeClientWithPairing({ + port, + commands: ["canvas.snapshot"], + platform: "darwin", + instanceId: displayName, + displayName, + onEvent: (evt) => { + if (evt.event === "node.invoke.request") { + resolveInvoke?.(evt.payload as { id?: string; nodeId?: string }); + } + }, + }); + + await expect + .poll(async () => { + const node = await findConnectedNodeByDisplayName(displayName); + return node?.commands?.toSorted() ?? []; + }, FAST_WAIT_OPTS) + .toEqual([]); + + const node = await findConnectedNodeByDisplayName(displayName); + const nodeId = requireNodeId(node?.nodeId, displayName); + const pairingList = await rpcReq<{ + pending?: Array<{ requestId?: string; nodeId?: string; commands?: string[] }>; + }>(ws, "node.pair.list", {}); + const pending = (pairingList.payload?.pending ?? []).find((entry) => entry.nodeId === nodeId); + expect(pending?.commands).toEqual(["canvas.snapshot"]); + + const approveRes = await rpcReq(ws, "node.pair.approve", { requestId: pending?.requestId }); + expect(approveRes.ok).toBe(true); + + await expect + .poll(async () => { + const refreshed = await findConnectedNodeByDisplayName(displayName); + return refreshed?.commands?.toSorted() ?? []; + }, FAST_WAIT_OPTS) + .toEqual(["canvas.snapshot"]); + + const invokeResP = rpcReq(ws, "node.invoke", { + nodeId, + command: "canvas.snapshot", + params: { format: "png" }, + idempotencyKey: "approved-live-node-command", + }); + const payload = await waitForInvoke(); + await nodeClient.request("node.invoke.result", { + id: payload.id ?? "", + nodeId: payload.nodeId ?? nodeId, + ok: true, + payloadJSON: JSON.stringify({ ok: true }), + }); + const invokeRes = await invokeResP; + expect(invokeRes.ok).toBe(true); + } finally { + await nodeClient?.stopAndWait(); + } + }); + + test("rechecks current allowlist before exposing approved live commands", async () => { + const displayName = "node-approve-live-commands-current-allowlist"; + let nodeClient: GatewayClient | undefined; + let configPath: string | undefined; + + try { + const deviceIdentity = loadOrCreateDeviceIdentity( + path.join( + os.tmpdir(), + `openclaw-node-current-allowlist-${Date.now()}-${Math.random()}.json`, + ), + ); + nodeClient = await connectNodeClientWithPairing({ + port, + commands: ["canvas.snapshot", "system.run"], + platform: "darwin", + instanceId: displayName, + displayName, + deviceIdentity, + }); + + await expect + .poll(async () => { + const node = await findConnectedNodeByDisplayName(displayName); + return node?.commands?.toSorted() ?? []; + }, FAST_WAIT_OPTS) + .toEqual([]); + + const node = await findConnectedNodeByDisplayName(displayName); + const nodeId = requireNodeId(node?.nodeId, displayName); + const pending = await getPendingNodePairing(nodeId); + expect(pending?.commands).toEqual(["canvas.snapshot", "system.run"]); + + configPath = getGatewayTestConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ gateway: { nodes: { denyCommands: ["system.run"] } } }, null, 2), + ); + + const approveRes = await rpcReq(ws, "node.pair.approve", { requestId: pending?.requestId }); + expect(approveRes.ok).toBe(true); + + await expect + .poll(async () => { + const refreshed = await findConnectedNodeByDisplayName(displayName); + return refreshed?.commands?.toSorted() ?? []; + }, FAST_WAIT_OPTS) + .toEqual(["canvas.snapshot"]); + + const invokeRes = await rpcReq(ws, "node.invoke", { + nodeId, + command: "system.run", + params: { command: ["echo", "stale"] }, + idempotencyKey: "stale-allowlist-system-run", + }); + expect(invokeRes.ok).toBe(false); + expect(invokeRes.error?.message ?? "").toContain("node command not allowed"); + } finally { + if (configPath) { + await fs.writeFile(configPath, "{}\n"); + } + await nodeClient?.stopAndWait(); + } + }); + test("records only allowlisted commands in pending node pairing requests", async () => { const deviceIdentityPath = path.join( os.tmpdir(), diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 07eff9dc86c..e65d109d494 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1283,6 +1283,14 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar dropIfSlow: true, }); } + const nodeConnectParams = connectParams as ConnectParams & { + declaredCaps?: string[]; + declaredCommands?: string[]; + declaredPermissions?: Record; + }; + nodeConnectParams.declaredCaps = reconciliation.declaredCaps; + nodeConnectParams.declaredCommands = reconciliation.declaredCommands; + nodeConnectParams.declaredPermissions = reconciliation.declaredPermissions; connectParams.caps = reconciliation.effectiveCaps; connectParams.commands = reconciliation.effectiveCommands; connectParams.permissions = reconciliation.effectivePermissions;