fix(auth): hand off qr bootstrap to bounded device tokens

This commit is contained in:
Nimrod Gutman
2026-04-03 13:05:33 +03:00
committed by Peter Steinberger
parent c4597992ca
commit a9140abea6
14 changed files with 808 additions and 241 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)