fix(pairing): preserve operator scopes for ios onboarding

This commit is contained in:
Nimrod Gutman
2026-02-20 14:16:00 +02:00
committed by Nimrod Gutman
parent 7ecfc1d93c
commit 1da23be302
3 changed files with 122 additions and 26 deletions

View File

@@ -2140,9 +2140,7 @@ private extension NodeAppModel {
clientId: clientId,
clientMode: "ui",
clientDisplayName: displayName,
// Operator traffic should authenticate via shared gateway auth only.
// Including device identity here can trigger duplicate pairing flows.
includeDeviceIdentity: false)
includeDeviceIdentity: true)
}
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {

View File

@@ -55,6 +55,38 @@ describe("device pairing tokens", () => {
expect(second.request.requestId).toBe(first.request.requestId);
});
test("merges pending roles/scopes for the same device before approval", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const first = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "node",
scopes: [],
},
baseDir,
);
const second = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.read", "operator.write"],
},
baseDir,
);
expect(second.created).toBe(false);
expect(second.request.requestId).toBe(first.request.requestId);
expect(second.request.roles).toEqual(["node", "operator"]);
expect(second.request.scopes).toEqual(["operator.read", "operator.write"]);
await approveDevicePairing(first.request.requestId, baseDir);
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.roles).toEqual(["node", "operator"]);
expect(paired?.scopes).toEqual(["operator.read", "operator.write"]);
});
test("generates base64url device tokens with 256-bit entropy output length", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);

View File

@@ -6,7 +6,6 @@ import {
pruneExpiredPending,
readJsonFile,
resolvePairingPaths,
upsertPendingPairingRequest,
writeJsonAtomic,
} from "./pairing-files.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
@@ -153,6 +152,61 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
return [...scopes];
}
function equalOptionalStringArray(a: string[] | undefined, b: string[] | undefined): boolean {
if (!a && !b) {
return true;
}
if (!a || !b || a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
function mergePendingDevicePairingRequest(
existing: DevicePairingPendingRequest,
incoming: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair"> & {
isRepair: boolean;
},
): { request: DevicePairingPendingRequest; changed: boolean } {
const existingRole = normalizeRole(existing.role);
const incomingRole = normalizeRole(incoming.role);
const nextRole = existingRole ?? incomingRole ?? undefined;
const nextRoles = mergeRoles(existing.roles, existing.role, incoming.role);
const nextScopes = mergeScopes(existing.scopes, incoming.scopes);
const nextSilent = Boolean(existing.silent && incoming.silent);
const nextRequest: DevicePairingPendingRequest = {
...existing,
displayName: incoming.displayName ?? existing.displayName,
platform: incoming.platform ?? existing.platform,
clientId: incoming.clientId ?? existing.clientId,
clientMode: incoming.clientMode ?? existing.clientMode,
role: nextRole,
roles: nextRoles,
scopes: nextScopes,
remoteIp: incoming.remoteIp ?? existing.remoteIp,
silent: nextSilent,
isRepair: existing.isRepair || incoming.isRepair,
ts: Date.now(),
};
const changed =
nextRequest.displayName !== existing.displayName ||
nextRequest.platform !== existing.platform ||
nextRequest.clientId !== existing.clientId ||
nextRequest.clientMode !== existing.clientMode ||
nextRequest.role !== existing.role ||
!equalOptionalStringArray(nextRequest.roles, existing.roles) ||
!equalOptionalStringArray(nextRequest.scopes, existing.scopes) ||
nextRequest.remoteIp !== existing.remoteIp ||
nextRequest.silent !== existing.silent ||
nextRequest.isRepair !== existing.isRepair;
return { request: nextRequest, changed };
}
function newToken() {
return generatePairingToken();
}
@@ -217,29 +271,41 @@ export async function requestDevicePairing(
if (!deviceId) {
throw new Error("deviceId required");
}
return await upsertPendingPairingRequest({
pendingById: state.pendingById,
isExisting: (pending) => pending.deviceId === deviceId,
isRepair: Boolean(state.pairedByDeviceId[deviceId]),
createRequest: (isRepair) => ({
requestId: randomUUID(),
deviceId,
publicKey: req.publicKey,
displayName: req.displayName,
platform: req.platform,
clientId: req.clientId,
clientMode: req.clientMode,
role: req.role,
roles: req.role ? [req.role] : undefined,
scopes: req.scopes,
remoteIp: req.remoteIp,
silent: req.silent,
const isRepair = Boolean(state.pairedByDeviceId[deviceId]);
const existing = Object.values(state.pendingById).find(
(pending) => pending.deviceId === deviceId,
);
if (existing) {
const merged = mergePendingDevicePairingRequest(existing, {
...req,
isRepair,
ts: Date.now(),
}),
persist: async () => await persistState(state, baseDir),
});
});
state.pendingById[existing.requestId] = merged.request;
if (merged.changed) {
await persistState(state, baseDir);
}
return { status: "pending" as const, request: merged.request, created: false };
}
const request: DevicePairingPendingRequest = {
requestId: randomUUID(),
deviceId,
publicKey: req.publicKey,
displayName: req.displayName,
platform: req.platform,
clientId: req.clientId,
clientMode: req.clientMode,
role: req.role,
roles: req.role ? [req.role] : undefined,
scopes: req.scopes,
remoteIp: req.remoteIp,
silent: req.silent,
isRepair,
ts: Date.now(),
};
state.pendingById[request.requestId] = request;
await persistState(state, baseDir);
return { status: "pending" as const, request, created: true };
});
}