Require re-pairing for node reconnect command upgrades (#62658)

* fix(node): require re-pairing for reconnect command upgrades

Co-authored-by: zsx <git@zsxsoft.com>

* fix(node): tighten reconnect pairing test polling

* docs(changelog): add node reconnect pairing entry

---------

Co-authored-by: zsx <git@zsxsoft.com>
Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-04-07 11:48:18 -07:00
committed by GitHub
parent 93ab2ac69d
commit a383878e97
3 changed files with 56 additions and 14 deletions

View File

@@ -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.

View File

@@ -22,6 +22,16 @@ export type NodeConnectPairingReconcileResult = {
pendingPairing?: PendingNodePairingResult;
};
function resolveApprovedReconnectCommands(params: {
pairedCommands: readonly string[] | undefined;
allowlist: Set<string>;
}) {
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,

View File

@@ -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 {