diff --git a/CHANGELOG.md b/CHANGELOG.md index f96fb745952..453b2cb94be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678) - Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07. - Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987. +- Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit. - Gateway/auth: invalidate existing shared-token and password WebSocket sessions when the configured secret rotates, so stale authenticated sockets cannot stay attached after token or password changes. (#62350) Thanks @pgondhi987. - Gateway/status and containers: auto-bind to `0.0.0.0` inside Docker and Podman environments, and probe local TLS gateways over `wss://` with self-signed fingerprint forwarding so container startup and loopback TLS status checks work again. (#61818, #61935) Thanks @openperf and @ThanhNguyxn07. - macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg. diff --git a/src/gateway/node-connect-reconcile.ts b/src/gateway/node-connect-reconcile.ts index a3ca9caf381..66ddcbb06b2 100644 --- a/src/gateway/node-connect-reconcile.ts +++ b/src/gateway/node-connect-reconcile.ts @@ -22,6 +22,16 @@ export type NodeConnectPairingReconcileResult = { pendingPairing?: PendingNodePairingResult; }; +function resolveApprovedReconnectCommands(params: { + pairedCommands: readonly string[] | undefined; + allowlist: Set; +}) { + return normalizeDeclaredNodeCommands({ + declaredCommands: Array.isArray(params.pairedCommands) ? params.pairedCommands : [], + allowlist: params.allowlist, + }); +} + function buildNodePairingRequestInput(params: { nodeId: string; connectParams: ConnectParams; @@ -76,6 +86,28 @@ export async function reconcileNodePairingOnConnect(params: { }; } + const approvedCommands = resolveApprovedReconnectCommands({ + pairedCommands: params.pairedNode.commands, + allowlist, + }); + const hasCommandUpgrade = declared.some((command) => !approvedCommands.includes(command)); + + if (hasCommandUpgrade) { + const pendingPairing = await params.requestPairing( + buildNodePairingRequestInput({ + nodeId, + connectParams: params.connectParams, + commands: declared, + remoteIp: params.reportedClientIp, + }), + ); + return { + nodeId, + effectiveCommands: approvedCommands, + pendingPairing, + }; + } + return { nodeId, effectiveCommands: declared, diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index cf6c1c5e267..2bc74310546 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -176,7 +176,7 @@ describe("gateway node pairing authorization", () => { } }); - test("does not pin connected node commands to the approved pairing record", async () => { + test("requests re-pairing when a paired node reconnects with upgraded commands", async () => { const started = await startServerWithClient("secret"); const pairedNode = await pairDeviceIdentity({ name: "node-command-pin", @@ -226,8 +226,7 @@ describe("gateway node pairing authorization", () => { (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, ); if ( - JSON.stringify(node?.commands?.toSorted() ?? []) === - JSON.stringify(["canvas.snapshot", "system.run"]) + JSON.stringify(node?.commands?.toSorted() ?? []) === JSON.stringify(["canvas.snapshot"]) ) { break; } @@ -238,7 +237,18 @@ describe("gateway node pairing authorization", () => { .find((entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected) ?.commands?.toSorted(), JSON.stringify(lastNodes), - ).toEqual(["canvas.snapshot", "system.run"]); + ).toEqual(["canvas.snapshot"]); + + await expect(listNodePairing()).resolves.toEqual( + expect.objectContaining({ + pending: [ + expect.objectContaining({ + nodeId: pairedNode.identity.deviceId, + commands: ["canvas.snapshot", "system.run"], + }), + ], + }), + ); } finally { controlWs?.close(); await firstClient?.stopAndWait(); @@ -249,7 +259,7 @@ describe("gateway node pairing authorization", () => { } }); - test("does not request repair pairing when a paired node reconnects with more commands", async () => { + test("requests re-pairing when a commandless paired node reconnects with system.run", async () => { const started = await startServerWithClient("secret"); const pairedNode = await pairDeviceIdentity({ name: "node-command-empty", @@ -289,10 +299,7 @@ describe("gateway node pairing authorization", () => { const node = lastNodes.find( (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, ); - if ( - JSON.stringify(node?.commands?.toSorted() ?? []) === - JSON.stringify(["canvas.snapshot", "system.run"]) - ) { + if (JSON.stringify(node?.commands?.toSorted() ?? []) === JSON.stringify([])) { break; } await new Promise((resolve) => setTimeout(resolve, 25)); @@ -300,14 +307,16 @@ describe("gateway node pairing authorization", () => { const repairedNode = lastNodes.find( (entry) => entry.nodeId === pairedNode.identity.deviceId && entry.connected, ); - expect(repairedNode?.commands?.toSorted(), JSON.stringify(lastNodes)).toEqual([ - "canvas.snapshot", - "system.run", - ]); + expect(repairedNode?.commands?.toSorted(), JSON.stringify(lastNodes)).toEqual([]); await expect(listNodePairing()).resolves.toEqual( expect.objectContaining({ - pending: [], + pending: [ + expect.objectContaining({ + nodeId: pairedNode.identity.deviceId, + commands: ["canvas.snapshot", "system.run"], + }), + ], }), ); } finally {