mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 20:04:32 +00:00
refactor: dedupe pending pairing request flow and add reuse tests
This commit is contained in:
@@ -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"]);
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user