Skip to content

Commit f40e412

Browse files
committed
fix: preserve paired node reconnects
1 parent b36b3fc commit f40e412

4 files changed

Lines changed: 118 additions & 10 deletions

File tree

src/gateway/node-connect-reconcile.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,29 @@ describe("reconcileNodePairingOnConnect", () => {
174174
expect(result.pendingPairing?.request.requestId).toBe("req-caps");
175175
});
176176

177+
it("preserves the approved surface when paired node upgrade pairing is throttled", async () => {
178+
const requestPairing = vi.fn(async () => null);
179+
180+
const result = await reconcileNodePairingOnConnect({
181+
cfg: {} as never,
182+
connectParams: makeNodeConnectParams({
183+
caps: ["camera", "screen"],
184+
commands: [],
185+
}),
186+
pairedNode: makePairedNode({
187+
caps: ["camera"],
188+
commands: [],
189+
}),
190+
requestPairing,
191+
});
192+
193+
expect(requestPairing).toHaveBeenCalledOnce();
194+
expect(result.effectiveCaps).toEqual(["camera"]);
195+
expect(result.effectiveCommands).toEqual([]);
196+
expect(result.declaredCaps).toEqual(["camera", "screen"]);
197+
expect(result.pendingPairing).toBeUndefined();
198+
});
199+
177200
it("requires a fresh pairing request when paired node permissions change", async () => {
178201
const requestPairing = makePendingPairingRequest("req-permissions");
179202

src/gateway/node-connect-reconcile.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export async function reconcileNodePairingOnConnect(params: {
112112
connectParams: ConnectParams;
113113
pairedNode: NodePairingPairedNode | null;
114114
reportedClientIp?: string;
115-
requestPairing: (input: NodePairingRequestInput) => Promise<RequestNodePairingResult>;
115+
requestPairing: (input: NodePairingRequestInput) => Promise<RequestNodePairingResult | null>;
116116
}): Promise<NodeConnectPairingReconcileResult> {
117117
const nodeId = params.connectParams.device?.id ?? params.connectParams.client.id;
118118
const policyNode = {
@@ -142,6 +142,9 @@ export async function reconcileNodePairingOnConnect(params: {
142142
remoteIp: params.reportedClientIp,
143143
}),
144144
);
145+
if (!pendingPairing) {
146+
throw new Error("node pairing request required");
147+
}
145148
return {
146149
nodeId,
147150
declaredCaps,
@@ -204,7 +207,7 @@ export async function reconcileNodePairingOnConnect(params: {
204207
effectiveCommands: effectiveApprovedDeclaredCommands,
205208
declaredPermissions,
206209
effectivePermissions: effectiveApprovedDeclaredPermissions,
207-
pendingPairing,
210+
...(pendingPairing ? { pendingPairing } : {}),
208211
};
209212
}
210213

src/gateway/server.node-pairing-rate-limit.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import path from "node:path";
44
import { describe, expect, test } from "vitest";
55
import { WebSocket } from "ws";
66
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
7-
import { listNodePairing } from "../infra/node-pairing.js";
7+
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
8+
import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js";
89
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
910
import {
1011
connectReq,
@@ -56,6 +57,21 @@ async function attemptNodePairing(port: number, identityPath: string) {
5657
}
5758
}
5859

60+
async function approveNodeIdentity(params: { identityPath: string; caps: string[] }) {
61+
const identity = loadOrCreateDeviceIdentity(params.identityPath);
62+
const request = await requestNodePairing({
63+
nodeId: identity.deviceId,
64+
platform: NODE_CLIENT.platform,
65+
deviceFamily: NODE_CLIENT.deviceFamily,
66+
caps: params.caps,
67+
});
68+
const approved = await approveNodePairing(request.request.requestId, {
69+
callerScopes: ["operator.pairing"],
70+
});
71+
expect(approved && !("status" in approved)).toBe(true);
72+
return identity;
73+
}
74+
5975
describe("node pairing rate limit", () => {
6076
test("limits concurrent first-time node pairing requests before the pairing lock", async () => {
6177
testState.gatewayAuth = {
@@ -91,4 +107,57 @@ describe("node pairing rate limit", () => {
91107
expect((await listNodePairing()).pending).toHaveLength(3);
92108
});
93109
});
110+
111+
test("keeps paired reconnects on the approved surface when upgrade pairing is limited", async () => {
112+
testState.gatewayAuth = {
113+
mode: "token",
114+
token: "secret",
115+
rateLimit: {
116+
maxAttempts: 3,
117+
windowMs: 60_000,
118+
lockoutMs: 60_000,
119+
exemptLoopback: false,
120+
},
121+
};
122+
await withGatewayServer(async ({ port }) => {
123+
const identityPrefix = path.join(
124+
os.tmpdir(),
125+
`openclaw-node-pairing-upgrade-${randomUUID()}`,
126+
);
127+
const pairedIdentityPath = `${identityPrefix}-paired.json`;
128+
await approveNodeIdentity({ identityPath: pairedIdentityPath, caps: ["camera"] });
129+
130+
const firstTimeResponses = await Promise.all(
131+
Array.from(
132+
{ length: 3 },
133+
async (_, index) => await attemptNodePairing(port, `${identityPrefix}-${index}.json`),
134+
),
135+
);
136+
expect(firstTimeResponses.filter((res) => res.ok)).toHaveLength(3);
137+
138+
const ws = await openWs(port);
139+
try {
140+
const reconnect = await connectReq(ws, {
141+
token: "secret",
142+
role: "node",
143+
scopes: [],
144+
client: NODE_CLIENT,
145+
caps: ["camera", "screen"],
146+
deviceIdentityPath: pairedIdentityPath,
147+
});
148+
expect(reconnect.ok).toBe(true);
149+
} finally {
150+
ws.close();
151+
await new Promise<void>((resolve) => {
152+
if (ws.readyState === WebSocket.CLOSED) {
153+
resolve();
154+
return;
155+
}
156+
ws.once("close", () => resolve());
157+
});
158+
}
159+
160+
expect((await listNodePairing()).pending).toHaveLength(3);
161+
});
162+
});
94163
});

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,18 +1614,31 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
16141614
}
16151615
if (role === "node") {
16161616
let reconciliation: Awaited<ReturnType<typeof reconcileNodePairingOnConnect>>;
1617+
const pairedNode = await getPairedNode(
1618+
connectParams.device?.id ?? connectParams.client.id,
1619+
);
16171620
try {
16181621
reconciliation = await reconcileNodePairingOnConnect({
16191622
cfg: getRuntimeConfig(),
16201623
connectParams,
1621-
pairedNode: await getPairedNode(connectParams.device?.id ?? connectParams.client.id),
1624+
pairedNode,
16221625
reportedClientIp,
1623-
requestPairing: async (input) =>
1624-
await requestNodePairingFromConnect({
1625-
input,
1626-
rateLimiter: authRateLimiter,
1627-
clientIp: browserRateLimitClientIp,
1628-
}),
1626+
requestPairing: async (input) => {
1627+
try {
1628+
return await requestNodePairingFromConnect({
1629+
input,
1630+
rateLimiter: authRateLimiter,
1631+
clientIp: browserRateLimitClientIp,
1632+
});
1633+
} catch (error) {
1634+
if (error instanceof NodePairingRateLimitError && pairedNode) {
1635+
// Paired upgrade reconnects can keep their approved surface;
1636+
// only the fresh pending request is throttled here.
1637+
return null;
1638+
}
1639+
throw error;
1640+
}
1641+
},
16291642
});
16301643
} catch (error) {
16311644
if (error instanceof NodePairingRateLimitError) {

0 commit comments

Comments
 (0)