refactor: dedupe pending pairing request flow and add reuse tests

This commit is contained in:
Peter Steinberger
2026-02-19 13:54:35 +00:00
parent d900d5efbd
commit 7a89049d1d
5 changed files with 117 additions and 51 deletions

View File

@@ -33,6 +33,28 @@ function requireToken(token: string | undefined): string {
} }
describe("device pairing tokens", () => { describe("device pairing tokens", () => {
test("reuses existing pending requests for the same device", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const first = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
},
baseDir,
);
const second = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
},
baseDir,
);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.request.requestId).toBe(first.request.requestId);
});
test("generates base64url device tokens with 256-bit entropy output length", async () => { test("generates base64url device tokens with 256-bit entropy output length", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]); await setupPairedOperatorDevice(baseDir, ["operator.admin"]);

View File

@@ -5,6 +5,7 @@ import {
pruneExpiredPending, pruneExpiredPending,
readJsonFile, readJsonFile,
resolvePairingPaths, resolvePairingPaths,
upsertPendingPairingRequest,
writeJsonAtomic, writeJsonAtomic,
} from "./pairing-files.js"; } from "./pairing-files.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
@@ -226,30 +227,29 @@ export async function requestDevicePairing(
if (!deviceId) { if (!deviceId) {
throw new Error("deviceId required"); throw new Error("deviceId required");
} }
const existing = Object.values(state.pendingById).find((p) => p.deviceId === deviceId);
if (existing) { return await upsertPendingPairingRequest({
return { status: "pending", request: existing, created: false }; pendingById: state.pendingById,
} isExisting: (pending) => pending.deviceId === deviceId,
const isRepair = Boolean(state.pairedByDeviceId[deviceId]); isRepair: Boolean(state.pairedByDeviceId[deviceId]),
const request: DevicePairingPendingRequest = { createRequest: (isRepair) => ({
requestId: randomUUID(), requestId: randomUUID(),
deviceId, deviceId,
publicKey: req.publicKey, publicKey: req.publicKey,
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
clientId: req.clientId, clientId: req.clientId,
clientMode: req.clientMode, clientMode: req.clientMode,
role: req.role, role: req.role,
roles: req.role ? [req.role] : undefined, roles: req.role ? [req.role] : undefined,
scopes: req.scopes, scopes: req.scopes,
remoteIp: req.remoteIp, remoteIp: req.remoteIp,
silent: req.silent, silent: req.silent,
isRepair, isRepair,
ts: Date.now(), ts: Date.now(),
}; }),
state.pendingById[request.requestId] = request; persist: async () => await persistState(state, baseDir),
await persistState(state, baseDir); });
return { status: "pending", request, created: true };
}); });
} }

View File

@@ -28,6 +28,28 @@ async function setupPairedNode(baseDir: string): Promise<string> {
} }
describe("node pairing tokens", () => { describe("node pairing tokens", () => {
test("reuses existing pending requests for the same node", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-"));
const first = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
},
baseDir,
);
const second = await requestNodePairing(
{
nodeId: "node-1",
platform: "darwin",
},
baseDir,
);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.request.requestId).toBe(first.request.requestId);
});
test("generates base64url node tokens with 256-bit entropy output length", async () => { test("generates base64url node tokens with 256-bit entropy output length", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-")); const baseDir = await mkdtemp(join(tmpdir(), "openclaw-node-pairing-"));
const token = await setupPairedNode(baseDir); const token = await setupPairedNode(baseDir);

View File

@@ -4,6 +4,7 @@ import {
pruneExpiredPending, pruneExpiredPending,
readJsonFile, readJsonFile,
resolvePairingPaths, resolvePairingPaths,
upsertPendingPairingRequest,
writeJsonAtomic, writeJsonAtomic,
} from "./pairing-files.js"; } from "./pairing-files.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
@@ -123,33 +124,30 @@ export async function requestNodePairing(
throw new Error("nodeId required"); throw new Error("nodeId required");
} }
const existing = Object.values(state.pendingById).find((p) => p.nodeId === nodeId); return await upsertPendingPairingRequest({
if (existing) { pendingById: state.pendingById,
return { status: "pending", request: existing, created: false }; isExisting: (pending) => pending.nodeId === nodeId,
} isRepair: Boolean(state.pairedByNodeId[nodeId]),
createRequest: (isRepair) => ({
const isRepair = Boolean(state.pairedByNodeId[nodeId]); requestId: randomUUID(),
const request: NodePairingPendingRequest = { nodeId,
requestId: randomUUID(), displayName: req.displayName,
nodeId, platform: req.platform,
displayName: req.displayName, version: req.version,
platform: req.platform, coreVersion: req.coreVersion,
version: req.version, uiVersion: req.uiVersion,
coreVersion: req.coreVersion, deviceFamily: req.deviceFamily,
uiVersion: req.uiVersion, modelIdentifier: req.modelIdentifier,
deviceFamily: req.deviceFamily, caps: req.caps,
modelIdentifier: req.modelIdentifier, commands: req.commands,
caps: req.caps, permissions: req.permissions,
commands: req.commands, remoteIp: req.remoteIp,
permissions: req.permissions, silent: req.silent,
remoteIp: req.remoteIp, isRepair,
silent: req.silent, ts: Date.now(),
isRepair, }),
ts: Date.now(), persist: async () => await persistState(state, baseDir),
}; });
state.pendingById[request.requestId] = request;
await persistState(state, baseDir);
return { status: "pending", request, created: true };
}); });
} }

View File

@@ -24,3 +24,27 @@ export function pruneExpiredPending<T extends { ts: number }>(
} }
} }
} }
export type PendingPairingRequestResult<TPending> = {
status: "pending";
request: TPending;
created: boolean;
};
export async function upsertPendingPairingRequest<TPending extends { requestId: string }>(params: {
pendingById: Record<string, TPending>;
isExisting: (pending: TPending) => boolean;
createRequest: (isRepair: boolean) => TPending;
isRepair: boolean;
persist: () => Promise<void>;
}): Promise<PendingPairingRequestResult<TPending>> {
const existing = Object.values(params.pendingById).find(params.isExisting);
if (existing) {
return { status: "pending", request: existing, created: false };
}
const request = params.createRequest(params.isRepair);
params.pendingById[request.requestId] = request;
await params.persist();
return { status: "pending", request, created: true };
}