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/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/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.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..313ee54c90a 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,30 @@ function mergeScopes(...items: Array): string[] | undefine return [...scopes]; } +function mergePendingDevicePairingRequest( + existing: DevicePairingPendingRequest, + incoming: Omit, + isRepair: boolean, +): DevicePairingPendingRequest { + const existingRole = normalizeRole(existing.role); + const incomingRole = normalizeRole(incoming.role); + return { + ...existing, + displayName: incoming.displayName ?? existing.displayName, + platform: incoming.platform ?? existing.platform, + clientId: incoming.clientId ?? existing.clientId, + clientMode: incoming.clientMode ?? existing.clientMode, + role: existingRole ?? incomingRole ?? undefined, + roles: mergeRoles(existing.roles, existing.role, incoming.role), + scopes: mergeScopes(existing.scopes, incoming.scopes), + remoteIp: incoming.remoteIp ?? existing.remoteIp, + // 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(), + }; +} + function newToken() { return generatePairingToken(); } @@ -217,29 +240,36 @@ export async function requestDevicePairing( if (!deviceId) { throw new Error("deviceId required"); } + 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); + state.pendingById[existing.requestId] = merged; + await persistState(state, baseDir); + return { status: "pending" as const, request: merged, created: false }; + } - 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, - isRepair, - ts: Date.now(), - }), - persist: async () => await persistState(state, baseDir), - }); + 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 }; }); }