From 20266ff7ddd51a6726d7216e2bb9d656129a9a18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 15:55:24 +0900 Subject: [PATCH] fix: preserve mobile bootstrap auth fallback (#60238) (thanks @ngutman) --- CHANGELOG.md | 3 + .../ai/openclaw/app/gateway/GatewaySession.kt | 79 +++++++++- .../app/gateway/GatewaySessionInvokeTest.kt | 140 +++++++++++++++++- .../Sources/OpenClawKit/GatewayChannel.swift | 34 ++++- .../GatewayNodeSessionTests.swift | 68 ++++++++- .../TalkSystemSpeechSynthesizerTests.swift | 1 + .../server/ws-connection/message-handler.ts | 6 +- 7 files changed, 318 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd59fb8e62f..9405f0a16dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ Docs: https://docs.openclaw.ai - Providers/OpenAI Codex: split native `contextWindow` from runtime `contextTokens` for `openai-codex/gpt-5.4`, keep the default effective cap at `272000`, and expose a per-model config override via `models.providers.*.models[].contextTokens`. - Android/Talk Mode: restore spoken assistant replies on node-scoped sessions by keeping reply routing synced to the resolved node session key and pausing mic capture during reply playback. (#60306) Thanks @MKV21. - Agents/fallback: persist selected fallback overrides before retry attempts start, prefer persisted overrides during live-session reconciliation, and keep provider-scoped auth-profile failover from snapping retries back to stale primary selections. +- Skills/uv install: block workspace `.env` from overriding `UV_PYTHON` and strip related interpreter override keys from uv skill-install subprocesses so repository-controlled env files cannot steer the selected Python runtime. (#59178) Thanks @pgondhi987. +- Telegram/reactions: preserve `reactionNotifications: "own"` across gateway restarts by persisting sent-message ownership state instead of treating cold cache as a permissive fallback. (#59207) Thanks @samzong. - Gateway/startup: detect PID recycling in gateway lock files on Windows and macOS, and add startup progress so stale lock conflicts no longer block healthy restarts. (#59843) Thanks @TonyDerek-dot. - Providers/compat: stop forcing OpenAI-only payload defaults on proxy and custom OpenAI-compatible routes, and preserve native vendor-specific reasoning, tool, and streaming behavior for Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, Z.ai, and other routed provider paths. - Providers/GitHub Copilot: route Claude models through Anthropic Messages with Copilot-compatible headers and Anthropic prompt-cache markers instead of forcing the OpenAI Responses transport. @@ -137,6 +139,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/reasoning: preserve reasoning and compaction markers while coalescing adjacent reply blocks so hidden reasoning does not leak when mixed with visible text on channels like WhatsApp. Thanks @mcaxtr. - Exec/gateway: reuse durable exact-command `allow-always` approvals in allowlist mode so repeated gateway exec reruns stop re-prompting or failing on allowlist misses. (#59880) Thanks @luoyanglang. - Telegram/local Bot API: trust absolute Bot API `file_path` values only under explicit `channels.telegram.trustedLocalFileRoots`, copy trusted local media into inbound storage, and reject untrusted absolute paths instead of reading arbitrary host files. (#60705) Thanks @jzakirov. +- Mobile/bootstrap auth: preserve durable primary device-token persistence on iOS and Android while storing bounded QR bootstrap handoff tokens only on trusted bootstrap transports, so shared-auth drift recovery and QR onboarding both keep working. (#60238) Thanks @ngutman. ## 2026.4.2 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index ccca391b0be..f88f97356ea 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -418,11 +418,63 @@ class GatewaySession( } throw GatewayConnectFailure(error) } - handleConnectSuccess(res, identity.deviceId) + handleConnectSuccess(res, identity.deviceId, selectedAuth.authSource) connectDeferred.complete(Unit) } - private fun handleConnectSuccess(res: RpcResponse, deviceId: String) { + private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean { + if (authSource != GatewayConnectAuthSource.BOOTSTRAP_TOKEN) return false + if (isLoopbackGatewayHost(endpoint.host)) return true + return tls != null + } + + private fun filteredBootstrapHandoffScopes(role: String, scopes: List): List? { + return when (role.trim()) { + "node" -> emptyList() + "operator" -> { + val allowedOperatorScopes = + setOf( + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ) + scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted() + } + else -> null + } + } + + private fun persistBootstrapHandoffToken( + deviceId: String, + role: String, + token: String, + scopes: List, + ) { + if (filteredBootstrapHandoffScopes(role, scopes) == null) return + deviceAuthStore.saveToken(deviceId, role, token) + } + + private fun persistIssuedDeviceToken( + authSource: GatewayConnectAuthSource, + deviceId: String, + role: String, + token: String, + scopes: List, + ) { + if (authSource == GatewayConnectAuthSource.BOOTSTRAP_TOKEN) { + if (!shouldPersistBootstrapHandoffTokens(authSource)) return + persistBootstrapHandoffToken(deviceId, role, token, scopes) + return + } + deviceAuthStore.saveToken(deviceId, role, token) + } + + private fun handleConnectSuccess( + res: RpcResponse, + deviceId: String, + authSource: GatewayConnectAuthSource, + ) { val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") pendingDeviceTokenRetry = false @@ -432,8 +484,27 @@ class GatewaySession( val authObj = obj["auth"].asObjectOrNull() val deviceToken = authObj?.get("deviceToken").asStringOrNull() val authRole = authObj?.get("role").asStringOrNull() ?: options.role + val authScopes = + authObj?.get("scopes").asArrayOrNull() + ?.mapNotNull { it.asStringOrNull() } + ?: emptyList() if (!deviceToken.isNullOrBlank()) { - deviceAuthStore.saveToken(deviceId, authRole, deviceToken) + persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes) + } + if (shouldPersistBootstrapHandoffTokens(authSource)) { + authObj?.get("deviceTokens").asArrayOrNull() + ?.mapNotNull { it.asObjectOrNull() } + ?.forEach { tokenEntry -> + val handoffToken = tokenEntry["deviceToken"].asStringOrNull() + val handoffRole = tokenEntry["role"].asStringOrNull() + val handoffScopes = + tokenEntry["scopes"].asArrayOrNull() + ?.mapNotNull { it.asStringOrNull() } + ?: emptyList() + if (!handoffToken.isNullOrBlank() && !handoffRole.isNullOrBlank()) { + persistBootstrapHandoffToken(deviceId, handoffRole, handoffToken, handoffScopes) + } + } } val rawCanvas = obj["canvasHostUrl"].asStringOrNull() canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null) @@ -899,6 +970,8 @@ private fun formatGatewayAuthorityHost(host: String): String { private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject +private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray + private fun JsonElement?.asStringOrNull(): String? = when (this) { is JsonNull -> null diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index 2cfa1be4866..fce89bb8b5f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -213,6 +213,137 @@ class GatewaySessionInvokeTest { } } + @Test + fun connect_storesPrimaryDeviceTokenFromSuccessfulSharedTokenConnect() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + when (method) { + "connect" -> { + webSocket.send( + connectResponseFrame( + id, + authJson = """{"deviceToken":"shared-node-token","role":"node","scopes":[]}""", + ), + ) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = "shared-auth-token", + bootstrapToken = null, + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + assertEquals("shared-node-token", harness.deviceAuthStore.loadToken(deviceId, "node")) + assertNull(harness.deviceAuthStore.loadToken(deviceId, "operator")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun bootstrapConnect_storesAdditionalBoundedDeviceTokensOnTrustedTransport() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + when (method) { + "connect" -> { + webSocket.send( + connectResponseFrame( + id, + authJson = + """{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.read","operator.talk.secrets","operator.write"]}]}""", + ), + ) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "bootstrap-token", + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + assertEquals("bootstrap-node-token", harness.deviceAuthStore.loadToken(deviceId, "node")) + assertEquals("bootstrap-operator-token", harness.deviceAuthStore.loadToken(deviceId, "operator")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun nonBootstrapConnect_ignoresAdditionalBootstrapDeviceTokens() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, _ -> + when (method) { + "connect" -> { + webSocket.send( + connectResponseFrame( + id, + authJson = + """{"deviceToken":"shared-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"shared-operator-token","role":"operator","scopes":["operator.approvals","operator.read"]}]}""", + ), + ) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = "shared-auth-token", + bootstrapToken = null, + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + assertEquals("shared-node-token", harness.deviceAuthStore.loadToken(deviceId, "node")) + assertNull(harness.deviceAuthStore.loadToken(deviceId, "operator")) + } finally { + shutdownHarness(harness, server) + } + } + @Test fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { val handshakeOrigin = AtomicReference(null) @@ -470,9 +601,14 @@ class GatewaySessionInvokeTest { } } - private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String { + private fun connectResponseFrame( + id: String, + canvasHostUrl: String? = null, + authJson: String? = null, + ): String { val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: "" - return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""" + val auth = authJson?.let { "\"auth\":$it," } ?: "" + return """{"type":"res","id":"$id","ok":true,"payload":{$canvas$auth"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""" } private fun startGatewayServer( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 5a828a8eab1..42c44e2e63e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -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, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index e5c7808a09d..f9959190761 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -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) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkSystemSpeechSynthesizerTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkSystemSpeechSynthesizerTests.swift index 407c150b24b..84754f04c89 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkSystemSpeechSynthesizerTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkSystemSpeechSynthesizerTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import OpenClawKit +@MainActor final class TalkSystemSpeechSynthesizerTests: XCTestCase { func testWatchdogTimeoutDefaultsToLatinProfile() { let timeout = TalkSystemSpeechSynthesizer.watchdogTimeoutSeconds( diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 2df1f39c8a9..ca4c8230f26 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1064,10 +1064,8 @@ export function attachGatewayWsMessageHandler(params: { issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, }); } - const bootstrapProfileForHello: DeviceBootstrapProfile | null = device - ? bootstrapProfile - : null; - if (device && bootstrapProfileForHello !== null) { + if (device && bootstrapProfile !== null) { + const bootstrapProfileForHello = bootstrapProfile as DeviceBootstrapProfile; for (const bootstrapRole of bootstrapProfileForHello.roles) { if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) { continue;