diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 5c72ceafc93..0bb9b06740c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1816,7 +1816,7 @@ private extension NodeAppModel { return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil } - static func shouldStartOperatorGatewayLoop( + nonisolated static func shouldStartOperatorGatewayLoop( token: String?, bootstrapToken: String?, password: String?, @@ -1837,7 +1837,7 @@ private extension NodeAppModel { return hasStoredOperatorToken } - static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? { + nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? { guard let config else { return nil } let trimmedBootstrapToken = config.bootstrapToken? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -1878,6 +1878,36 @@ private extension NodeAppModel { GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId) } + private func handleSuccessfulBootstrapGatewayOnboarding( + url: URL, + stableID: String, + token: String?, + password: String?, + nodeOptions: GatewayConnectOptions, + sessionBox: WebSocketSessionBox?) async + { + self.clearPersistedGatewayBootstrapTokenIfNeeded() + if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop( + token: token, + bootstrapToken: nil, + password: password, + stableID: stableID) + { + self.startOperatorGatewayLoop( + url: url, + stableID: stableID, + token: token, + bootstrapToken: nil, + password: password, + nodeOptions: nodeOptions, + sessionBox: sessionBox) + } + + // QR bootstrap onboarding should surface the system notification permission + // prompt immediately so visible APNs alerts work without a second manual step. + _ = await self.requestNotificationAuthorizationIfNeeded() + } + func refreshBackgroundReconnectSuppressionIfNeeded(source: String) { guard self.isBackgrounded else { return } guard !self.backgroundReconnectSuppressed else { return } @@ -2049,13 +2079,14 @@ private extension NodeAppModel { fallbackToken: token, fallbackBootstrapToken: bootstrapToken, fallbackPassword: password) + let connectedOptions = currentOptions GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)") try await self.nodeGateway.connect( url: url, token: reconnectAuth.token, bootstrapToken: reconnectAuth.bootstrapToken, password: reconnectAuth.password, - connectOptions: currentOptions, + connectOptions: connectedOptions, sessionBox: sessionBox, onConnected: { [weak self] in guard let self else { return } @@ -2071,24 +2102,13 @@ private extension NodeAppModel { reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty == false if usedBootstrapToken { - await MainActor.run { - self.clearPersistedGatewayBootstrapTokenIfNeeded() - if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop( - token: reconnectAuth.token, - bootstrapToken: nil, - password: reconnectAuth.password, - stableID: stableID) - { - self.startOperatorGatewayLoop( - url: url, - stableID: stableID, - token: reconnectAuth.token, - bootstrapToken: nil, - password: reconnectAuth.password, - nodeOptions: currentOptions, - sessionBox: sessionBox) - } - } + await self.handleSuccessfulBootstrapGatewayOnboarding( + url: url, + stableID: stableID, + token: reconnectAuth.token, + password: reconnectAuth.password, + nodeOptions: connectedOptions, + sessionBox: sessionBox) } let relayData = await MainActor.run { ( @@ -2249,7 +2269,7 @@ private extension NodeAppModel { func makeOperatorConnectOptions(clientId: String, displayName: String?) -> GatewayConnectOptions { GatewayConnectOptions( role: "operator", - scopes: ["operator.read", "operator.write", "operator.talk.secrets"], + scopes: ["operator.read", "operator.write", "operator.approvals", "operator.talk.secrets"], caps: [], commands: [], permissions: [:], @@ -3137,11 +3157,18 @@ extension NodeAppModel { await self.applyPendingForegroundNodeActions(mapped, trigger: "test") } + func _test_makeOperatorConnectOptions( + clientId: String, + displayName: String? + ) -> GatewayConnectOptions { + self.makeOperatorConnectOptions(clientId: clientId, displayName: displayName) + } + static func _test_currentDeepLinkKey() -> String { self.expectedDeepLinkKey() } - static func _test_shouldStartOperatorGatewayLoop( + nonisolated static func _test_shouldStartOperatorGatewayLoop( token: String?, bootstrapToken: String?, password: String?, @@ -3154,6 +3181,30 @@ extension NodeAppModel { hasStoredOperatorToken: hasStoredOperatorToken) } + nonisolated static func _test_clearingBootstrapToken( + in config: GatewayConnectConfig? + ) -> GatewayConnectConfig? { + self.clearingBootstrapToken(in: config) + } + + func _test_handleSuccessfulBootstrapGatewayOnboarding() async { + await self.handleSuccessfulBootstrapGatewayOnboarding( + url: URL(string: "wss://gateway.example")!, + stableID: "test-gateway", + token: nil, + password: nil, + nodeOptions: GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios", + clientMode: "node", + clientDisplayName: nil), + sessionBox: nil) + } + } #endif // swiftlint:enable type_body_length file_length diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 6bb7ce66ddc..865c16a5ea9 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -70,6 +70,19 @@ import UIKit } } + @Test @MainActor func operatorConnectOptionsRequestApprovalScope() { + let appModel = NodeAppModel() + let options = appModel._test_makeOperatorConnectOptions( + clientId: "openclaw-ios", + displayName: "OpenClaw iOS") + + #expect(options.role == "operator") + #expect(options.scopes.contains("operator.read")) + #expect(options.scopes.contains("operator.write")) + #expect(options.scopes.contains("operator.approvals")) + #expect(options.scopes.contains("operator.talk.secrets")) + } + @Test @MainActor func loadLastConnectionReadsSavedValues() { let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection") defer { diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index bda4b7c1a0d..31d1c11aeb6 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -2,6 +2,7 @@ import OpenClawKit import Foundation import Testing import UIKit +import UserNotifications @testable import OpenClaw private func makeAgentDeepLinkURL( @@ -68,6 +69,28 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer } } +private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable { + var status: NotificationAuthorizationStatus = .notDetermined + var requestAuthorizationResult = false + var requestAuthorizationCalls = 0 + + func authorizationStatus() async -> NotificationAuthorizationStatus { + self.status + } + + func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool { + self.requestAuthorizationCalls += 1 + if self.requestAuthorizationResult { + self.status = .authorized + } else { + self.status = .denied + } + return self.requestAuthorizationResult + } + + func add(_: UNNotificationRequest) async throws {} +} + @Suite(.serialized) struct NodeAppModelInvokeTests { @Test @MainActor func decodeParamsFailsWithoutJSON() { #expect(throws: Error.self) { @@ -127,6 +150,15 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer ) } + @Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async { + let center = MockBootstrapNotificationCenter() + let appModel = NodeAppModel(notificationCenter: center) + + await appModel._test_handleSuccessfulBootstrapGatewayOnboarding() + + #expect(center.requestAuthorizationCalls == 1) + } + @Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() { let config = GatewayConnectConfig( url: URL(string: "wss://gateway.example")!, @@ -145,7 +177,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer clientMode: "node", clientDisplayName: nil)) - let cleared = NodeAppModel.clearingBootstrapToken(in: config) + let cleared = NodeAppModel._test_clearingBootstrapToken(in: config) #expect(cleared?.bootstrapToken == nil) #expect(cleared?.url == config.url) #expect(cleared?.stableID == config.stableID) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index ba7bee46c6d..5a828a8eab1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -542,6 +542,52 @@ public actor GatewayChannelActor { authSource: authSource) } + private func shouldPersistBootstrapHandoffTokens() -> Bool { + guard self.lastAuthSource == .bootstrapToken else { return false } + let scheme = self.url.scheme?.lowercased() + if scheme == "wss" { + return true + } + if let host = self.url.host, LoopbackHost.isLoopback(host) { + return true + } + return false + } + + private func filteredBootstrapHandoffScopes(role: String, scopes: [String]) -> [String]? { + let normalizedRole = role.trimmingCharacters(in: .whitespacesAndNewlines) + switch normalizedRole { + case "node": + return [] + case "operator": + let allowedOperatorScopes: Set = [ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ] + return Array(Set(scopes.filter { allowedOperatorScopes.contains($0) })).sorted() + default: + return nil + } + } + + private func persistBootstrapHandoffToken( + deviceId: String, + role: String, + token: String, + scopes: [String] + ) { + guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else { + return + } + _ = DeviceAuthStore.storeToken( + deviceId: deviceId, + role: role, + token: token, + scopes: filteredScopes) + } + private func handleConnectResponse( _ res: ResponseFrame, identity: DeviceIdentity?, @@ -572,18 +618,34 @@ public actor GatewayChannelActor { } else if let tick = ok.policy["tickIntervalMs"]?.value as? Int { self.tickIntervalMs = Double(tick) } - if let auth = ok.auth, - let deviceToken = auth["deviceToken"]?.value as? String { - let authRole = auth["role"]?.value as? String ?? role - let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])? - .compactMap { $0.value as? String } ?? [] - if let identity { - _ = DeviceAuthStore.storeToken( + if let auth = ok.auth, let identity, self.shouldPersistBootstrapHandoffTokens() { + if let deviceToken = auth["deviceToken"]?.value as? String { + let authRole = auth["role"]?.value as? String ?? role + let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])? + .compactMap { $0.value as? String } ?? [] + self.persistBootstrapHandoffToken( deviceId: identity.deviceId, role: authRole, token: deviceToken, scopes: scopes) } + if let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable] { + for entry in tokenEntries { + guard let rawEntry = entry.value as? [String: ProtoAnyCodable], + let deviceToken = rawEntry["deviceToken"]?.value as? String, + let authRole = rawEntry["role"]?.value as? String + else { + continue + } + let scopes = (rawEntry["scopes"]?.value as? [ProtoAnyCodable])? + .compactMap { $0.value as? String } ?? [] + self.persistBootstrapHandoffToken( + deviceId: identity.deviceId, + role: authRole, + token: deviceToken, + scopes: scopes) + } + } } self.lastTick = Date() self.tickTask?.cancel() diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index b8c57ba6a2b..54e5bdc5417 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -13,6 +13,7 @@ private extension NSLock { private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable { private let lock = NSLock() + private let helloAuth: [String: Any]? private var _state: URLSessionTask.State = .suspended private var connectRequestId: String? private var connectAuth: [String: Any]? @@ -20,6 +21,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda private var pendingReceiveHandler: (@Sendable (Result) -> Void)? + init(helloAuth: [String: Any]? = nil) { + self.helloAuth = helloAuth + } + var state: URLSessionTask.State { get { self.lock.withLock { self._state } } set { self.lock.withLock { self._state = newValue } } @@ -79,11 +84,11 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda for _ in 0..<50 { let id = self.lock.withLock { self.connectRequestId } if let id { - return .data(Self.connectOkData(id: id)) + return .data(Self.connectOkData(id: id, auth: self.helloAuth)) } try await Task.sleep(nanoseconds: 1_000_000) } - return .data(Self.connectOkData(id: "connect")) + return .data(Self.connectOkData(id: "connect", auth: self.helloAuth)) } func receive( @@ -110,8 +115,8 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() } - private static func connectOkData(id: String) -> Data { - let payload: [String: Any] = [ + private static func connectOkData(id: String, auth: [String: Any]? = nil) -> Data { + var payload: [String: Any] = [ "type": "hello-ok", "protocol": 2, "server": [ @@ -137,6 +142,9 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda "tickIntervalMs": 30_000, ], ] + if let auth { + payload["auth"] = auth + } let frame: [String: Any] = [ "type": "res", "id": id, @@ -149,9 +157,14 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable { private let lock = NSLock() + private let helloAuth: [String: Any]? private var tasks: [FakeGatewayWebSocketTask] = [] private var makeCount = 0 + init(helloAuth: [String: Any]? = nil) { + self.helloAuth = helloAuth + } + func snapshotMakeCount() -> Int { self.lock.withLock { self.makeCount } } @@ -164,7 +177,7 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked _ = url return self.lock.withLock { self.makeCount += 1 - let task = FakeGatewayWebSocketTask() + let task = FakeGatewayWebSocketTask(helloAuth: self.helloAuth) self.tasks.append(task) return WebSocketTaskBox(task: task) } @@ -234,6 +247,145 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } + @Test + func bootstrapHelloStoresAdditionalDeviceTokens() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let identity = DeviceIdentityStore.loadOrCreate() + let session = FakeGatewayWebSocketSession(helloAuth: [ + "deviceToken": "node-device-token", + "role": "node", + "scopes": [], + "issuedAtMs": 1000, + "deviceTokens": [ + [ + "deviceToken": "node-device-token", + "role": "node", + "scopes": ["operator.admin"], + "issuedAtMs": 1000, + ], + [ + "deviceToken": "operator-device-token", + "role": "operator", + "scopes": [ + "operator.admin", + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], + "issuedAtMs": 1001, + ], + ], + ]) + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: true) + + try await gateway.connect( + url: URL(string: "wss://example.invalid")!, + token: nil, + bootstrapToken: "fresh-bootstrap-token", + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node")) + let operatorEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator")) + #expect(nodeEntry.token == "node-device-token") + #expect(nodeEntry.scopes == []) + #expect(operatorEntry.token == "operator-device-token") + #expect(operatorEntry.scopes.contains("operator.approvals")) + #expect(!operatorEntry.scopes.contains("operator.admin")) + + await gateway.disconnect() + } + + @Test + func nonBootstrapHelloDoesNotOverwriteStoredDeviceTokens() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let identity = DeviceIdentityStore.loadOrCreate() + let session = FakeGatewayWebSocketSession(helloAuth: [ + "deviceToken": "server-node-token", + "role": "node", + "scopes": [], + "deviceTokens": [ + [ + "deviceToken": "server-operator-token", + "role": "operator", + "scopes": ["operator.admin"], + ], + ], + ]) + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "node", + clientDisplayName: "iOS Test", + includeDeviceIdentity: true) + + try await gateway.connect( + url: URL(string: "wss://example.invalid")!, + token: "shared-token", + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + #expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node") == nil) + #expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil) + + await gateway.disconnect() + } + @Test func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() { let normalized = canonicalizeCanvasHostUrl( diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index d5ebadd2dbd..3a4c671f5a4 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -96,6 +96,19 @@ export const HelloOkSchema = Type.Object( role: NonEmptyString, scopes: Type.Array(NonEmptyString), issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + deviceTokens: Type.Optional( + Type.Array( + Type.Object( + { + deviceToken: NonEmptyString, + role: NonEmptyString, + scopes: Type.Array(NonEmptyString), + issuedAtMs: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, + ), + ), + ), }, { additionalProperties: false }, ), diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 421801e861b..db221764f71 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -711,9 +711,12 @@ export function registerControlUiAndPairingSuite(): void { restoreGatewayToken(prevToken); }); - test("auto-approves fresh node-only bootstrap pairing and revokes the token after connect", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + test("auto-approves fresh node bootstrap pairing from qr setup code", async () => { + const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = + await import("../infra/device-bootstrap.js"); + const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); + const { getPairedDevice, listDevicePairing, verifyDeviceToken } = + await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); ws.close(); @@ -729,12 +732,7 @@ export function registerControlUiAndPairingSuite(): void { }; try { - const issued = await issueDeviceBootstrapToken({ - profile: { - roles: ["node"], - scopes: [], - }, - }); + const issued = await issueDeviceBootstrapToken(); const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); const initial = await connectReq(wsBootstrap, { skipDefaultAuth: true, @@ -752,168 +750,84 @@ export function registerControlUiAndPairingSuite(): void { deviceToken?: string; role?: string; scopes?: string[]; + deviceTokens?: Array<{ + deviceToken?: string; + role?: string; + scopes?: string[]; + }>; }; } | undefined; expect(initialPayload?.type).toBe("hello-ok"); const issuedDeviceToken = initialPayload?.auth?.deviceToken; + const issuedOperatorToken = initialPayload?.auth?.deviceTokens?.find( + (entry) => entry.role === "operator", + )?.deviceToken; expect(issuedDeviceToken).toBeDefined(); + expect(issuedOperatorToken).toBeDefined(); expect(initialPayload?.auth?.role).toBe("node"); expect(initialPayload?.auth?.scopes ?? []).toEqual([]); + expect(initialPayload?.auth?.deviceTokens?.some((entry) => entry.role === "node")).toBe( + false, + ); + expect( + initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes, + ).toEqual( + expect.arrayContaining([ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ]), + ); const afterBootstrap = await listDevicePairing(); expect( afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId), ).toEqual([]); const paired = await getPairedDevice(identity.deviceId); - expect(paired?.roles).toEqual(expect.arrayContaining(["node"])); + expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); + expect(paired?.approvedScopes ?? []).toEqual( + expect.arrayContaining([ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ]), + ); expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); - if (!issuedDeviceToken) { - throw new Error("expected hello-ok auth.deviceToken for bootstrap onboarding"); + expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken); + if (!issuedDeviceToken || !issuedOperatorToken) { + throw new Error("expected hello-ok auth.deviceTokens for bootstrap onboarding"); } - wsBootstrap.close(); - - const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const replay = await connectReq(wsReplay, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(replay.ok).toBe(false); - expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, - ); - wsReplay.close(); - - const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const reconnect = await connectReq(wsReconnect, { - skipDefaultAuth: true, - deviceToken: issuedDeviceToken, - role: "node", - scopes: [], - client, - deviceIdentityPath: identityPath, - }); - expect(reconnect.ok).toBe(true); - wsReconnect.close(); - } finally { - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("keeps setup bootstrap tokens valid until operator approval completes", async () => { - const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); - const { approveDevicePairing, getPairedDevice, listDevicePairing } = - await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startServerWithClient("secret"); - ws.close(); - - const { identityPath, identity, client } = await createOperatorIdentityFixture( - "openclaw-bootstrap-setup-profile-", - ); - const nodeClient = { - ...client, - id: "openclaw-android", - mode: "node", - }; - const operatorClient = { - ...client, - id: "openclaw-android", - mode: "ui", - }; - const operatorScopes = ["operator.read", "operator.write", "operator.talk.secrets"]; - - try { - const issued = await issueDeviceBootstrapToken(); - - const wsNode = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const nodeConnect = await connectReq(wsNode, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client: nodeClient, - deviceIdentityPath: identityPath, - }); - expect(nodeConnect.ok).toBe(true); - wsNode.close(); - - const pairedAfterNode = await getPairedDevice(identity.deviceId); - expect(pairedAfterNode?.roles).toEqual(expect.arrayContaining(["node"])); - - const wsOperatorPending = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const operatorPending = await connectReq(wsOperatorPending, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: operatorScopes, - client: operatorClient, - deviceIdentityPath: identityPath, - }); - expect(operatorPending.ok).toBe(false); - expect((operatorPending.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.PAIRING_REQUIRED, - ); - wsOperatorPending.close(); - - const pending = (await listDevicePairing()).pending.filter( - (entry) => entry.deviceId === identity.deviceId, - ); - expect(pending).toHaveLength(1); - const pendingRequest = pending[0]; - if (!pendingRequest) { - throw new Error("expected pending pairing request"); - } - await approveDevicePairing(pendingRequest.requestId, { - callerScopes: pendingRequest.scopes ?? ["operator.admin"], + await new Promise((resolve) => { + if (wsBootstrap.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + wsBootstrap.once("close", () => resolve()); + wsBootstrap.close(); }); - const wsNodeReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const nodeReconnect = await connectReq(wsNodeReconnect, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "node", - scopes: [], - client: nodeClient, - deviceIdentityPath: identityPath, - }); - expect(nodeReconnect.ok).toBe(true); - wsNodeReconnect.close(); + await expect( + verifyDeviceBootstrapToken({ + token: issued.token, + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + role: "node", + scopes: [], + }), + ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); - const wsOperatorApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const operatorApproved = await connectReq(wsOperatorApproved, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: operatorScopes, - client: operatorClient, - deviceIdentityPath: identityPath, - }); - expect(operatorApproved.ok).toBe(true); - wsOperatorApproved.close(); - - const pairedAfterOperator = await getPairedDevice(identity.deviceId); - expect(pairedAfterOperator?.roles).toEqual(expect.arrayContaining(["node", "operator"])); - - const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); - const replay = await connectReq(wsReplay, { - skipDefaultAuth: true, - bootstrapToken: issued.token, - role: "operator", - scopes: operatorScopes, - client: operatorClient, - deviceIdentityPath: identityPath, - }); - expect(replay.ok).toBe(false); - expect((replay.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID, - ); - wsReplay.close(); + await expect( + verifyDeviceToken({ + deviceId: identity.deviceId, + token: issuedDeviceToken, + role: "node", + scopes: [], + }), + ).resolves.toEqual({ ok: true }); } finally { await server.close(); restoreGatewayToken(prevToken); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 6a10817ba15..2df1f39c8a9 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -3,10 +3,9 @@ import os from "node:os"; import type { WebSocket } from "ws"; import { loadConfig } from "../../../config/config.js"; import { - getDeviceBootstrapTokenProfile, - redeemDeviceBootstrapTokenProfile, - restoreDeviceBootstrapToken, + getBoundDeviceBootstrapProfile, revokeDeviceBootstrapToken, + restoreDeviceBootstrapToken, verifyDeviceBootstrapToken, } from "../../../infra/device-bootstrap.js"; import { @@ -14,6 +13,7 @@ import { normalizeDevicePublicKeyBase64Url, } from "../../../infra/device-identity.js"; import { + approveBootstrapDevicePairing, approveDevicePairing, ensureDeviceToken, getPairedDevice, @@ -34,6 +34,7 @@ import { upsertPresence } from "../../../infra/system-presence.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; +import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isBrowserOperatorUiClient, @@ -714,10 +715,8 @@ export function attachGatewayWsMessageHandler(params: { rejectUnauthorized(authResult); return; } - const bootstrapProfile = - authMethod === "bootstrap-token" && bootstrapTokenCandidate - ? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate }) - : null; + let bootstrapProfile: DeviceBootstrapProfile | null = null; + let shouldConsumeBootstrapTokenAfterHello = false; const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ isControlUi, @@ -824,6 +823,21 @@ export function attachGatewayWsMessageHandler(params: { allowedScopes: pairedScopes, }); }; + if ( + bootstrapProfile === null && + authMethod === "bootstrap-token" && + reason === "not-paired" && + role === "node" && + scopes.length === 0 && + !existingPairedDevice && + bootstrapTokenCandidate + ) { + bootstrapProfile = await getBoundDeviceBootstrapProfile({ + token: bootstrapTokenCandidate, + deviceId: device.id, + publicKey: devicePublicKey, + }); + } const allowSilentLocalPairing = shouldAllowSilentLocalPairing({ locality: pairingLocality, hasBrowserOriginHeader, @@ -831,19 +845,26 @@ export function attachGatewayWsMessageHandler(params: { isWebchat, reason, }); - // Bootstrap setup can silently pair the first fresh node connect. Keep the token alive - // until the issued profile is fully redeemed so follow-up operator connects can still - // present the same bootstrap token while approval is pending. + // QR bootstrap onboarding stays single-use, but only consume the bootstrap token + // after the hello-ok path succeeds so reconnects can recover from pre-hello failures. const allowSilentBootstrapPairing = authMethod === "bootstrap-token" && reason === "not-paired" && role === "node" && scopes.length === 0 && - !existingPairedDevice; + !existingPairedDevice && + bootstrapProfile !== null; + const bootstrapProfileForSilentApproval = allowSilentBootstrapPairing + ? bootstrapProfile + : null; + const bootstrapPairingRoles = bootstrapProfileForSilentApproval + ? Array.from(new Set([role, ...bootstrapProfileForSilentApproval.roles])) + : undefined; const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, ...clientPairingMetadata, + ...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles } : {}), silent: reason === "scope-upgrade" ? false @@ -868,10 +889,18 @@ export function attachGatewayWsMessageHandler(params: { return replacementPending?.requestId; }; if (pairing.request.silent === true) { - approved = await approveDevicePairing(pairing.request.requestId, { - callerScopes: scopes, - }); + approved = bootstrapProfileForSilentApproval + ? await approveBootstrapDevicePairing( + pairing.request.requestId, + bootstrapProfileForSilentApproval, + ) + : await approveDevicePairing(pairing.request.requestId, { + callerScopes: scopes, + }); if (approved?.status === "approved") { + if (allowSilentBootstrapPairing) { + shouldConsumeBootstrapTokenAfterHello = true; + } logGateway.info( `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, ); @@ -1021,6 +1050,47 @@ export function attachGatewayWsMessageHandler(params: { const deviceToken = device ? await ensureDeviceToken({ deviceId: device.id, role, scopes }) : null; + const bootstrapDeviceTokens: Array<{ + deviceToken: string; + role: string; + scopes: string[]; + issuedAtMs: number; + }> = []; + if (deviceToken) { + bootstrapDeviceTokens.push({ + deviceToken: deviceToken.token, + role: deviceToken.role, + scopes: deviceToken.scopes, + issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, + }); + } + const bootstrapProfileForHello: DeviceBootstrapProfile | null = device + ? bootstrapProfile + : null; + if (device && bootstrapProfileForHello !== null) { + for (const bootstrapRole of bootstrapProfileForHello.roles) { + if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) { + continue; + } + const bootstrapRoleScopes = + bootstrapRole === "operator" ? bootstrapProfileForHello.scopes : []; + const extraToken = await ensureDeviceToken({ + deviceId: device.id, + role: bootstrapRole, + scopes: bootstrapRoleScopes, + }); + if (!extraToken) { + continue; + } + bootstrapDeviceTokens.push({ + deviceToken: extraToken.token, + role: extraToken.role, + scopes: extraToken.scopes, + issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs, + }); + } + } + if (role === "node") { const reconciliation = await reconcileNodePairingOnConnect({ cfg: loadConfig(), @@ -1111,6 +1181,9 @@ export function attachGatewayWsMessageHandler(params: { role: deviceToken.role, scopes: deviceToken.scopes, issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, + ...(bootstrapDeviceTokens.length > 1 + ? { deviceTokens: bootstrapDeviceTokens.slice(1) } + : {}), } : undefined, policy: { @@ -1188,32 +1261,20 @@ export function attachGatewayWsMessageHandler(params: { let consumedBootstrapTokenRecord: | Awaited>["record"] | undefined; - if ( - authMethod === "bootstrap-token" && - bootstrapProfile && - bootstrapTokenCandidate && - device - ) { + if (shouldConsumeBootstrapTokenAfterHello && bootstrapTokenCandidate && device) { try { - const redemption = await redeemDeviceBootstrapTokenProfile({ + const revoked = await revokeDeviceBootstrapToken({ token: bootstrapTokenCandidate, - role, - scopes, }); - if (redemption.fullyRedeemed) { - const revoked = await revokeDeviceBootstrapToken({ - token: bootstrapTokenCandidate, - }); - consumedBootstrapTokenRecord = revoked.record; - if (!revoked.removed) { - logGateway.warn( - `bootstrap token revoke skipped after profile redemption device=${device.id}`, - ); - } + consumedBootstrapTokenRecord = revoked.record; + if (!revoked.removed) { + logGateway.warn( + `bootstrap token revoke skipped after bootstrap handoff device=${device.id}`, + ); } } catch (err) { logGateway.warn( - `bootstrap token redemption bookkeeping failed device=${device.id}: ${formatForLog(err)}`, + `bootstrap token consume failed after device-token handoff device=${device.id}: ${formatForLog(err)}`, ); } } diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index ca77ff84fa3..ff7c83641a2 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -5,6 +5,7 @@ import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { clearDeviceBootstrapTokens, DEVICE_BOOTSTRAP_TOKEN_TTL_MS, + getBoundDeviceBootstrapProfile, getDeviceBootstrapTokenProfile, issueDeviceBootstrapToken, redeemDeviceBootstrapTokenProfile, @@ -12,6 +13,7 @@ import { revokeDeviceBootstrapToken, verifyDeviceBootstrapToken, } from "./device-bootstrap.js"; +import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } from "./device-identity.js"; const tempDirs = createTrackedTempDirs(); const createTempDir = () => tempDirs.make("openclaw-device-bootstrap-test-"); @@ -68,7 +70,7 @@ describe("device bootstrap tokens", () => { issuedAtMs: Date.now(), profile: { roles: ["node", "operator"], - scopes: ["operator.read", "operator.talk.secrets", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }, }); }); @@ -103,7 +105,12 @@ describe("device bootstrap tokens", () => { await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( { roles: ["node", "operator"], - scopes: ["operator.read", "operator.talk.secrets", "operator.write"], + scopes: [ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], }, ); await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull(); @@ -129,7 +136,12 @@ describe("device bootstrap tokens", () => { await expect( verifyBootstrapToken(baseDir, issued.token, { role: "operator", - scopes: ["operator.read", "operator.write", "operator.talk.secrets"], + scopes: [ + "operator.approvals", + "operator.read", + "operator.write", + "operator.talk.secrets", + ], }), ).resolves.toEqual({ ok: true }); await expect( @@ -137,7 +149,12 @@ describe("device bootstrap tokens", () => { baseDir, token: issued.token, role: "operator", - scopes: ["operator.read", "operator.write", "operator.talk.secrets"], + scopes: [ + "operator.approvals", + "operator.read", + "operator.write", + "operator.talk.secrets", + ], }), ).resolves.toEqual({ recorded: true, @@ -214,7 +231,12 @@ describe("device bootstrap tokens", () => { issuedAtMs, profile: { roles: ["node", "operator"], - scopes: ["operator.read", "operator.talk.secrets", "operator.write"], + scopes: [ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], }, }, }, @@ -358,6 +380,37 @@ describe("device bootstrap tokens", () => { expect(parsed[issued.token]?.token).toBe(issued.token); }); + it("accepts equivalent public key encodings after binding the bootstrap token", async () => { + const baseDir = await createTempDir(); + const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "device.json")); + const issued = await issueDeviceBootstrapToken({ baseDir }); + const rawPublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + + await expect( + verifyBootstrapToken(baseDir, issued.token, { + deviceId: identity.deviceId, + publicKey: identity.publicKeyPem, + }), + ).resolves.toEqual({ ok: true }); + await expect( + verifyBootstrapToken(baseDir, issued.token, { + deviceId: identity.deviceId, + publicKey: rawPublicKey, + }), + ).resolves.toEqual({ ok: true }); + await expect( + getBoundDeviceBootstrapProfile({ + token: issued.token, + deviceId: identity.deviceId, + publicKey: rawPublicKey, + baseDir, + }), + ).resolves.toEqual({ + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + }); + }); + it("rejects a second device identity after the first verification binds the token", async () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index c64c91f1092..6d8600cb25a 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -6,6 +6,7 @@ import { type DeviceBootstrapProfileInput, } from "../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; +import { normalizeDevicePublicKeyBase64Url } from "./device-identity.js"; import { resolvePairingPaths } from "./pairing-files.js"; import { createAsyncLock, @@ -115,6 +116,17 @@ function bootstrapProfileSatisfiesProfile(params: { return true; } +function normalizeBootstrapPublicKey(publicKey: string): string { + const trimmed = publicKey.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.includes("BEGIN") || /[+/=]/.test(trimmed)) { + return normalizeDevicePublicKeyBase64Url(trimmed) ?? trimmed; + } + return trimmed; +} + async function loadState(baseDir?: string): Promise { const bootstrapPath = resolveBootstrapPath(baseDir); const rawState = (await readJsonFile(bootstrapPath)) ?? {}; @@ -306,7 +318,7 @@ export async function verifyDeviceBootstrapToken(params: { const [tokenKey, record] = found; const deviceId = params.deviceId.trim(); - const publicKey = params.publicKey.trim(); + const publicKey = normalizeBootstrapPublicKey(params.publicKey); const role = params.role.trim(); if (!deviceId || !publicKey || !role) { return { ok: false, reason: "bootstrap_token_invalid" }; @@ -326,7 +338,10 @@ export async function verifyDeviceBootstrapToken(params: { } const boundDeviceId = record.deviceId?.trim(); - const boundPublicKey = record.publicKey?.trim(); + const boundPublicKey = + typeof record.publicKey === "string" + ? normalizeBootstrapPublicKey(record.publicKey) + : undefined; if (boundDeviceId || boundPublicKey) { if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) { return { ok: false, reason: "bootstrap_token_invalid" }; @@ -353,3 +368,38 @@ export async function verifyDeviceBootstrapToken(params: { return { ok: true }; }); } + +export async function getBoundDeviceBootstrapProfile(params: { + token: string; + deviceId: string; + publicKey: string; + baseDir?: string; +}): Promise { + return await withLock(async () => { + const state = await loadState(params.baseDir); + const providedToken = params.token.trim(); + if (!providedToken) { + return null; + } + const found = Object.entries(state).find(([, candidate]) => + verifyPairingToken(providedToken, candidate.token), + ); + if (!found) { + return null; + } + const [, record] = found; + const deviceId = params.deviceId.trim(); + const publicKey = normalizeBootstrapPublicKey(params.publicKey); + if (!deviceId || !publicKey) { + return null; + } + const recordPublicKey = + typeof record.publicKey === "string" + ? normalizeBootstrapPublicKey(record.publicKey) + : undefined; + if (record.deviceId?.trim() !== deviceId || recordPublicKey !== publicKey) { + return null; + } + return resolvePersistedBootstrapProfile(record); + }); +} diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 024a109d082..63b04886c27 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -2,8 +2,10 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; +import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js"; import { + approveBootstrapDevicePairing, approveDevicePairing, clearDevicePairing, ensureDeviceToken, @@ -96,23 +98,27 @@ async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: strin await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2)); } -async function mutatePairedOperatorDevice(baseDir: string, mutate: (device: PairedDevice) => void) { +async function mutatePairedDevice( + baseDir: string, + deviceId: string, + mutate: (device: PairedDevice) => void, +) { const { pairedPath } = resolvePairingPaths(baseDir, "devices"); const pairedByDeviceId = JSON.parse(await readFile(pairedPath, "utf8")) as Record< string, PairedDevice >; - const device = pairedByDeviceId["device-1"]; + const device = pairedByDeviceId[deviceId]; expect(device).toBeDefined(); if (!device) { - throw new Error("expected paired operator device"); + throw new Error(`expected paired device ${deviceId}`); } mutate(device); await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2)); } async function clearPairedOperatorApprovalBaseline(baseDir: string) { - await mutatePairedOperatorDevice(baseDir, (device) => { + await mutatePairedDevice(baseDir, "device-1", (device) => { delete device.approvedScopes; delete device.scopes; }); @@ -558,6 +564,68 @@ describe("device pairing tokens", () => { ).resolves.toEqual({ ok: true }); }); + test("normalizes legacy node token scopes back to [] on re-approval", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + await setupPairedNodeDevice(baseDir); + + await mutatePairedDevice(baseDir, "node-1", (device) => { + const nodeToken = device.tokens?.node; + expect(nodeToken).toBeDefined(); + if (!nodeToken) { + throw new Error("expected paired node token"); + } + nodeToken.scopes = ["operator.read"]; + }); + + const repair = await requestDevicePairing( + { + deviceId: "node-1", + publicKey: "public-key-node-1", + role: "node", + }, + baseDir, + ); + await approveDevicePairing(repair.request.requestId, { callerScopes: [] }, baseDir); + + const paired = await getPairedDevice("node-1", baseDir); + expect(paired?.scopes).toEqual([]); + expect(paired?.approvedScopes).toEqual([]); + expect(paired?.tokens?.node?.scopes).toEqual([]); + }); + + test("bootstrap pairing seeds node and operator device tokens explicitly", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + const request = await requestDevicePairing( + { + deviceId: "bootstrap-device-1", + publicKey: "bootstrap-public-key-1", + role: "node", + roles: ["node", "operator"], + scopes: [], + silent: true, + }, + baseDir, + ); + + await expect( + approveBootstrapDevicePairing( + request.request.requestId, + PAIRING_SETUP_BOOTSTRAP_PROFILE, + baseDir, + ), + ).resolves.toEqual(expect.objectContaining({ status: "approved" })); + + const paired = await getPairedDevice("bootstrap-device-1", baseDir); + expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"])); + expect(paired?.approvedScopes).toEqual( + expect.arrayContaining(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes), + ); + expect(paired?.tokens?.node?.scopes).toEqual([]); + expect(paired?.tokens?.operator?.scopes).toEqual( + expect.arrayContaining(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes), + ); + }); + test("verifies token and rejects mismatches", async () => { const { baseDir, token } = await setupOperatorToken(["operator.read"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 56cde1657a1..071acd7ac50 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; +import type { DeviceBootstrapProfile } from "../shared/device-bootstrap-profile.js"; import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js"; import { createAsyncLock, @@ -92,6 +93,7 @@ type DevicePairingStateFile = { }; const PENDING_TTL_MS = 5 * 60 * 1000; +const OPERATOR_ROLE = "operator"; const OPERATOR_SCOPE_PREFIX = "operator."; const withLock = createAsyncLock(); @@ -484,12 +486,14 @@ export async function approveDevicePairing( ): Promise; export async function approveDevicePairing( requestId: string, - options: { callerScopes?: readonly string[] }, + options: { callerScopes?: readonly string[]; approvedScopesOverride?: readonly string[] }, baseDir?: string, ): Promise; export async function approveDevicePairing( requestId: string, - optionsOrBaseDir?: { callerScopes?: readonly string[] } | string, + optionsOrBaseDir?: + | { callerScopes?: readonly string[]; approvedScopesOverride?: readonly string[] } + | string, maybeBaseDir?: string, ): Promise { const options = @@ -503,10 +507,14 @@ export async function approveDevicePairing( if (!pending) { return null; } - const requestedRoles = mergeRoles(pending.roles, pending.role) ?? []; - const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) => - scope.startsWith(OPERATOR_SCOPE_PREFIX), + const approvedScopesOverride = normalizeDeviceAuthScopes( + options?.approvedScopesOverride ? [...options.approvedScopesOverride] : undefined, ); + const requestedRoles = mergeRoles(pending.roles, pending.role) ?? []; + const requestedOperatorScopes = normalizeDeviceAuthScopes([ + ...(pending.scopes ?? []), + ...approvedScopesOverride, + ]).filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX)); if (requestedOperatorScopes.length > 0) { if (!options?.callerScopes) { return { @@ -514,11 +522,11 @@ export async function approveDevicePairing( missingScope: requestedOperatorScopes[0] ?? "callerScopes-required", }; } - if (!requestedRoles.includes("operator")) { + if (!requestedRoles.includes(OPERATOR_ROLE)) { return { status: "forbidden", missingScope: requestedOperatorScopes[0] }; } const missingScope = resolveMissingRequestedScope({ - role: "operator", + role: OPERATOR_ROLE, requestedScopes: requestedOperatorScopes, allowedScopes: options.callerScopes, }); @@ -532,6 +540,7 @@ export async function approveDevicePairing( const approvedScopes = mergeScopes( existing?.approvedScopes ?? existing?.scopes, pending.scopes, + approvedScopesOverride, ); const tokens = existing?.tokens ? { ...existing.tokens } : {}; for (const roleForToken of requestedRoles) { @@ -578,6 +587,90 @@ export async function approveDevicePairing( }); } +export async function approveBootstrapDevicePairing( + requestId: string, + bootstrapProfile: DeviceBootstrapProfile, + baseDir?: string, +): Promise { + // QR bootstrap handoff is an explicit trust path: it can seed the bounded + // node/operator baseline from the verified bootstrap profile without routing + // operator scope approval through the generic interactive approval checker. + const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? []; + const approvedScopes = normalizeDeviceAuthScopes([...bootstrapProfile.scopes]); + return await withLock(async () => { + const state = await loadState(baseDir); + const pending = state.pendingById[requestId]; + if (!pending) { + return null; + } + const requestedRoles = resolveRequestedRoles(pending); + const missingRole = requestedRoles.find((role) => !approvedRoles.includes(role)); + if (missingRole) { + return { status: "forbidden", missingScope: missingRole }; + } + const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) => + scope.startsWith(OPERATOR_SCOPE_PREFIX), + ); + const missingScope = resolveMissingRequestedScope({ + role: OPERATOR_ROLE, + requestedScopes: requestedOperatorScopes, + allowedScopes: approvedScopes, + }); + if (missingScope) { + return { status: "forbidden", missingScope }; + } + + const now = Date.now(); + const existing = state.pairedByDeviceId[pending.deviceId]; + const roles = mergeRoles( + existing?.roles, + existing?.role, + pending.roles, + pending.role, + approvedRoles, + ); + const nextApprovedScopes = mergeScopes( + existing?.approvedScopes ?? existing?.scopes, + pending.scopes, + approvedScopes, + ); + const tokens = existing?.tokens ? { ...existing.tokens } : {}; + for (const roleForToken of approvedRoles) { + const existingToken = tokens[roleForToken]; + const tokenScopes = roleForToken === OPERATOR_ROLE ? approvedScopes : []; + tokens[roleForToken] = buildDeviceAuthToken({ + role: roleForToken, + scopes: tokenScopes, + existing: existingToken, + now, + ...(existingToken ? { rotatedAtMs: now } : {}), + }); + } + + const device: PairedDevice = { + deviceId: pending.deviceId, + publicKey: pending.publicKey, + displayName: pending.displayName, + platform: pending.platform, + deviceFamily: pending.deviceFamily, + clientId: pending.clientId, + clientMode: pending.clientMode, + role: pending.role, + roles, + scopes: nextApprovedScopes, + approvedScopes: nextApprovedScopes, + remoteIp: pending.remoteIp, + tokens, + createdAtMs: existing?.createdAtMs ?? now, + approvedAtMs: now, + }; + delete state.pendingById[requestId]; + state.pairedByDeviceId[device.deviceId] = device; + await persistState(state, baseDir); + return { status: "approved", requestId, device }; + }); +} + export async function rejectDevicePairing( requestId: string, baseDir?: string, diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 87b29bad288..71b62905a5c 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -92,7 +92,12 @@ describe("pairing setup code", () => { expect.objectContaining({ profile: { roles: ["node", "operator"], - scopes: ["operator.read", "operator.talk.secrets", "operator.write"], + scopes: [ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], }, }), ); diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index 014734d2197..3dbebbe8f9b 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -12,7 +12,7 @@ export type DeviceBootstrapProfileInput = { export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { roles: ["node", "operator"], - scopes: ["operator.read", "operator.talk.secrets", "operator.write"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], }; function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {