mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(pairing): preserve operator scopes for ios onboarding
This commit is contained in:
committed by
Nimrod Gutman
parent
7ecfc1d93c
commit
1da23be302
@@ -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? {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user