fix: preserve mobile bootstrap auth fallback (#60238) (thanks @ngutman)

This commit is contained in:
Peter Steinberger
2026-04-04 15:55:24 +09:00
parent 226ca1f324
commit 20266ff7dd
7 changed files with 318 additions and 13 deletions

View File

@@ -588,6 +588,31 @@ public actor GatewayChannelActor {
scopes: filteredScopes)
}
private func persistIssuedDeviceToken(
authSource: GatewayAuthSource,
deviceId: String,
role: String,
token: String,
scopes: [String]
) {
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
return
}
self.persistBootstrapHandoffToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes)
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
@@ -618,18 +643,21 @@ public actor GatewayChannelActor {
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
if let auth = ok.auth, let identity, self.shouldPersistBootstrapHandoffTokens() {
if let auth = ok.auth, let identity {
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(
self.persistIssuedDeviceToken(
authSource: self.lastAuthSource,
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
if let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable] {
if self.shouldPersistBootstrapHandoffTokens(),
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,

View File

@@ -190,6 +190,7 @@ private actor SeqGapProbe {
func value() -> Bool { self.saw }
}
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
@@ -321,7 +322,7 @@ struct GatewayNodeSessionTests {
}
@Test
func nonBootstrapHelloDoesNotOverwriteStoredDeviceTokens() async throws {
func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -374,6 +375,71 @@ struct GatewayNodeSessionTests {
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node"))
#expect(nodeEntry.token == "server-node-token")
#expect(nodeEntry.scopes == [])
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil)
await gateway.disconnect()
}
@Test
func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() 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": "untrusted-node-token",
"role": "node",
"scopes": [],
"deviceTokens": [
[
"deviceToken": "untrusted-operator-token",
"role": "operator",
"scopes": [
"operator.approvals",
"operator.read",
],
],
],
])
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: "ws://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)
})
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node") == nil)
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil)

View File

@@ -1,6 +1,7 @@
import XCTest
@testable import OpenClawKit
@MainActor
final class TalkSystemSpeechSynthesizerTests: XCTestCase {
func testWatchdogTimeoutDefaultsToLatinProfile() {
let timeout = TalkSystemSpeechSynthesizer.watchdogTimeoutSeconds(