From 1da23be302d5901079ed755a4622a35a72ae3d4c Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 20 Feb 2026 14:16:00 +0200 Subject: [PATCH 1/4] fix(pairing): preserve operator scopes for ios onboarding --- apps/ios/Sources/Model/NodeAppModel.swift | 4 +- src/infra/device-pairing.test.ts | 32 +++++++ src/infra/device-pairing.ts | 112 +++++++++++++++++----- 3 files changed, 122 insertions(+), 26 deletions(-) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index d9206c41efd..a18e3d58ba9 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -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? { diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 9620da2f76d..d0794de7583 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -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"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index c776f9bf15d..50cb4a8a1f3 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -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[] | 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 & { + 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 }; }); } From 8775d34fba8988ee8c05b1ede30b411a5f098507 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 20 Feb 2026 14:24:35 +0200 Subject: [PATCH 2/4] fix(pairing): simplify pending merge and harden mixed-role onboarding --- .../Sources/OpenClawKit/GatewayChannel.swift | 6 +- src/gateway/server.auth.e2e.test.ts | 95 +++++++++++++++++++ src/infra/device-pairing.ts | 64 +++---------- 3 files changed, 112 insertions(+), 53 deletions(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index fc0be4a94a3..f6aac26977a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -85,9 +85,9 @@ public struct GatewayConnectOptions: Sendable { public var clientId: String public var clientMode: String public var clientDisplayName: String? - // When false, the connection omits the signed device identity payload. - // This is useful for secondary "operator" connections where the shared gateway token - // should authorize without triggering device pairing flows. + // When false, the connection omits the signed device identity payload and cannot use + // device-scoped auth (role/scope upgrades will require pairing). Keep this true for + // role/scoped sessions such as operator UI clients. public var includeDeviceIdentity: Bool public init( diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 93854341d61..b529a9ff2f3 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -948,6 +948,101 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); + test("single approval captures pending node and operator roles for the same device", async () => { + const { mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing } = + await import("../infra/device-pairing.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-")); + const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); + const client = { + id: GATEWAY_CLIENT_NAMES.TEST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.TEST, + }; + const buildDevice = (role: "operator" | "node", scopes: string[], nonce?: string) => { + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: client.id, + clientMode: client.mode, + role, + scopes, + signedAtMs, + token: "secret", + nonce, + }); + return { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + }; + const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { host: "gateway.example" }, + }); + const challengePromise = onceMessage<{ + type?: string; + event?: string; + payload?: Record | null; + }>(socket, (o) => o.type === "event" && o.event === "connect.challenge"); + await new Promise((resolve) => socket.once("open", resolve)); + const challenge = await challengePromise; + const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + const result = await connectReq(socket, { + token: "secret", + role, + scopes, + client, + device: buildDevice(role, scopes, String(nonce)), + }); + socket.close(); + return result; + }; + + const nodeConnect = await connectWithNonce("node", []); + expect(nodeConnect.ok).toBe(false); + expect(nodeConnect.error?.message ?? "").toContain("pairing required"); + + const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]); + expect(operatorConnect.ok).toBe(false); + expect(operatorConnect.error?.message ?? "").toContain("pairing required"); + + const pending = await listDevicePairing(); + expect(pending.pending).toHaveLength(1); + expect(pending.pending[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"])); + expect(pending.pending[0]?.scopes).toEqual( + expect.arrayContaining(["operator.read", "operator.write"]), + ); + if (!pending.pending[0]) { + throw new Error("expected pending pairing request"); + } + await approveDevicePairing(pending.pending[0].requestId); + + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); + expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"])); + + const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]); + expect(approvedOperatorConnect.ok).toBe(true); + + const afterApproval = await listDevicePairing(); + expect(afterApproval.pending).toEqual([]); + + await server.close(); + restoreGatewayToken(prevToken); + }); + test("allows operator.read connect when device is paired with operator.admin", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 50cb4a8a1f3..313ee54c90a 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -152,59 +152,28 @@ function mergeScopes(...items: Array): 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 & { - isRepair: boolean; - }, -): { request: DevicePairingPendingRequest; changed: boolean } { + incoming: Omit, + isRepair: boolean, +): DevicePairingPendingRequest { 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 = { + return { ...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, + role: existingRole ?? incomingRole ?? undefined, + roles: mergeRoles(existing.roles, existing.role, incoming.role), + scopes: mergeScopes(existing.scopes, incoming.scopes), remoteIp: incoming.remoteIp ?? existing.remoteIp, - silent: nextSilent, - isRepair: existing.isRepair || incoming.isRepair, + // If either request is interactive, keep the pending request visible for approval. + silent: Boolean(existing.silent && incoming.silent), + isRepair: existing.isRepair || 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() { @@ -276,15 +245,10 @@ export async function requestDevicePairing( (pending) => pending.deviceId === deviceId, ); if (existing) { - const merged = mergePendingDevicePairingRequest(existing, { - ...req, - isRepair, - }); - state.pendingById[existing.requestId] = merged.request; - if (merged.changed) { - await persistState(state, baseDir); - } - return { status: "pending" as const, request: merged.request, created: false }; + const merged = mergePendingDevicePairingRequest(existing, req, isRepair); + state.pendingById[existing.requestId] = merged; + await persistState(state, baseDir); + return { status: "pending" as const, request: merged, created: false }; } const request: DevicePairingPendingRequest = { From ac0c1c26b1848f526b05252384cf6c8d1b894d5a Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 20 Feb 2026 14:28:31 +0200 Subject: [PATCH 3/4] fix: preserve ios bg refresh plist key and handle web login retry failures --- apps/ios/project.yml | 2 ++ src/web/login.coverage.test.ts | 16 ++++++++++++++++ src/web/login.ts | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 45c5b11048a..9b43db118ef 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -100,6 +100,8 @@ targets: UIBackgroundModes: - audio - remote-notification + BGTaskSchedulerPermittedIdentifiers: + - ai.openclaw.ios.bgrefresh NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network. NSAppTransportSecurity: NSAllowsArbitraryLoadsInWebContent: true diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts index 8b3673006eb..3c92cf942c1 100644 --- a/src/web/login.coverage.test.ts +++ b/src/web/login.coverage.test.ts @@ -80,6 +80,22 @@ describe("loginWeb coverage", () => { expect(secondSock.ws.close).toHaveBeenCalled(); }); + it("formats retry failure when restart login also closes", async () => { + formatErrorMock.mockReturnValueOnce("status=408 Request Time-out QR refs attempts ended"); + waitForWaConnectionMock + .mockRejectedValueOnce({ output: { statusCode: 515 } }) + .mockRejectedValueOnce({ + output: { + statusCode: 408, + payload: { error: "Request Time-out", message: "QR refs attempts ended" }, + }, + }); + + await expect(loginWeb(false, waitForWaConnectionMock as never)).rejects.toThrow( + /status=408 Request Time-out QR refs attempts ended/i, + ); + }); + it("clears creds and throws when logged out", async () => { waitForWaConnectionMock.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.loggedOut }, diff --git a/src/web/login.ts b/src/web/login.ts index b336f8ebe4f..edecaba3328 100644 --- a/src/web/login.ts +++ b/src/web/login.ts @@ -45,6 +45,12 @@ export async function loginWeb( await wait(retry); console.log(success("✅ Linked after restart; web session ready.")); return; + } catch (retryErr) { + const formatted = formatError(retryErr); + console.error( + danger(`WhatsApp Web connection ended after restart before fully opening. ${formatted}`), + ); + throw new Error(formatted, { cause: retryErr }); } finally { setTimeout(() => retry.ws?.close(), 500); } From 741435aacd4c0b09b1c5ce57c2b9f66426e96b8a Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 20 Feb 2026 14:41:35 +0200 Subject: [PATCH 4/4] fix(web): remove unrelated login changes --- src/web/login.coverage.test.ts | 16 ---------------- src/web/login.ts | 6 ------ 2 files changed, 22 deletions(-) diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts index 3c92cf942c1..8b3673006eb 100644 --- a/src/web/login.coverage.test.ts +++ b/src/web/login.coverage.test.ts @@ -80,22 +80,6 @@ describe("loginWeb coverage", () => { expect(secondSock.ws.close).toHaveBeenCalled(); }); - it("formats retry failure when restart login also closes", async () => { - formatErrorMock.mockReturnValueOnce("status=408 Request Time-out QR refs attempts ended"); - waitForWaConnectionMock - .mockRejectedValueOnce({ output: { statusCode: 515 } }) - .mockRejectedValueOnce({ - output: { - statusCode: 408, - payload: { error: "Request Time-out", message: "QR refs attempts ended" }, - }, - }); - - await expect(loginWeb(false, waitForWaConnectionMock as never)).rejects.toThrow( - /status=408 Request Time-out QR refs attempts ended/i, - ); - }); - it("clears creds and throws when logged out", async () => { waitForWaConnectionMock.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.loggedOut }, diff --git a/src/web/login.ts b/src/web/login.ts index edecaba3328..b336f8ebe4f 100644 --- a/src/web/login.ts +++ b/src/web/login.ts @@ -45,12 +45,6 @@ export async function loginWeb( await wait(retry); console.log(success("✅ Linked after restart; web session ready.")); return; - } catch (retryErr) { - const formatted = formatError(retryErr); - console.error( - danger(`WhatsApp Web connection ended after restart before fully opening. ${formatted}`), - ); - throw new Error(formatted, { cause: retryErr }); } finally { setTimeout(() => retry.ws?.close(), 500); }