From 451190d625693153dfdca800fdbb9a05b2c21b6a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 5 Jul 2026 05:39:01 +0200 Subject: [PATCH] fix(mobile): authenticate terminal page with stored operator token --- .../main/java/ai/openclaw/app/NodeRuntime.kt | 64 +++++++++++++++++-- .../openclaw/app/GatewayBootstrapAuthTest.kt | 42 ++++++++++++ .../Sources/Terminal/TerminalHubScreen.swift | 33 +++++++++- apps/ios/Tests/TerminalHubScreenTests.swift | 27 +++++++- 4 files changed, 156 insertions(+), 10 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 42d9c87d0f92..a8ef11233d56 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -661,6 +661,7 @@ class NodeRuntime( _gatewayUpdateAvailable.value = hello.updateAvailable _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey)) + refreshGatewayControlPage() updateStatus { operatorConnectionProblem = null operatorConnected = true @@ -1872,6 +1873,7 @@ class NodeRuntime( activeGatewayAuth = auth val tls = connectionManager.resolveTlsParams(endpoint) val storedOperatorEntry = loadStoredRoleDeviceAuthEntry("operator") + refreshGatewayControlPage(endpoint, auth, storedOperatorEntry?.token) val usesStoredOperatorDeviceToken = operatorSessionUsesStoredDeviceToken(auth, storedOperatorEntry?.token) val operatorAuth = @@ -1971,6 +1973,24 @@ class NodeRuntime( private fun isCurrentConnectAttempt(connectAttemptId: Long): Boolean = connectAttemptSeq.get() == connectAttemptId + private fun refreshGatewayControlPage( + endpoint: GatewayEndpoint? = connectedEndpoint, + auth: GatewayConnectAuth = activeGatewayAuth ?: resolveGatewayConnectAuth(), + storedOperatorToken: String? = loadStoredRoleDeviceAuthEntry("operator")?.token, + ) { + if (endpoint == null) { + _gatewayControlPage.value = null + return + } + val pageAuth = resolveGatewayControlPageAuth(auth, storedOperatorToken) + _gatewayControlPage.value = + GatewayControlPage( + baseUrl = gatewayControlPageBaseUrl(endpoint), + token = pageAuth.token, + password = pageAuth.password, + ) + } + private fun connectAfterTlsCheck( endpoint: GatewayEndpoint, auth: GatewayConnectAuth, @@ -1978,12 +1998,6 @@ class NodeRuntime( ) { if (!isCurrentConnectAttempt(connectAttemptId)) return connectedEndpoint = endpoint - _gatewayControlPage.value = - GatewayControlPage( - baseUrl = gatewayControlPageBaseUrl(endpoint), - token = auth.token?.trim()?.takeIf { it.isNotEmpty() }, - password = auth.password?.trim()?.takeIf { it.isNotEmpty() }, - ) updateStatus { operatorConnectionProblem = null nodeConnectionProblem = null @@ -3478,6 +3492,44 @@ internal fun resolveOperatorSessionConnectAuth( ) } +internal fun resolveGatewayControlPageAuth( + auth: NodeRuntime.GatewayConnectAuth, + storedOperatorToken: String?, +): NodeRuntime.GatewayConnectAuth { + val explicitToken = auth.token?.trim()?.takeIf { it.isNotEmpty() } + if (explicitToken != null) { + return NodeRuntime.GatewayConnectAuth( + token = explicitToken, + bootstrapToken = null, + password = null, + ) + } + + val explicitPassword = auth.password?.trim()?.takeIf { it.isNotEmpty() } + if (explicitPassword != null) { + return NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = null, + password = explicitPassword, + ) + } + + val storedToken = storedOperatorToken?.trim()?.takeIf { it.isNotEmpty() } + if (storedToken != null) { + return NodeRuntime.GatewayConnectAuth( + token = storedToken, + bootstrapToken = null, + password = null, + ) + } + + return NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = null, + password = null, + ) +} + internal fun operatorSessionUsesStoredDeviceToken( auth: NodeRuntime.GatewayConnectAuth, storedOperatorToken: String?, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 44318c77d051..072631b94b6a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -213,6 +213,48 @@ class GatewayBootstrapAuthTest { ) } + @Test + fun resolveGatewayControlPageAuthFallsBackToStoredOperatorToken() { + val resolved = + resolveGatewayControlPageAuth( + auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), + storedOperatorToken = " stored-token ", + ) + + assertEquals( + NodeRuntime.GatewayConnectAuth(token = "stored-token", bootstrapToken = null, password = null), + resolved, + ) + } + + @Test + fun resolveGatewayControlPageAuthPrefersExplicitSharedAuth() { + assertEquals( + NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null), + resolveGatewayControlPageAuth( + auth = + NodeRuntime.GatewayConnectAuth( + token = " shared-token ", + bootstrapToken = "bootstrap-1", + password = "shared-password", + ), + storedOperatorToken = "stored-token", + ), + ) + assertEquals( + NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = "shared-password"), + resolveGatewayControlPageAuth( + auth = + NodeRuntime.GatewayConnectAuth( + token = null, + bootstrapToken = "bootstrap-1", + password = " shared-password ", + ), + storedOperatorToken = "stored-token", + ), + ) + } + @Test fun operatorConnectScopesForAuthUsesNativeScopesWhenNoStoredOperatorMetadata() { assertEquals( diff --git a/apps/ios/Sources/Terminal/TerminalHubScreen.swift b/apps/ios/Sources/Terminal/TerminalHubScreen.swift index d572ba80d537..a35b7d6b8a3f 100644 --- a/apps/ios/Sources/Terminal/TerminalHubScreen.swift +++ b/apps/ios/Sources/Terminal/TerminalHubScreen.swift @@ -1,3 +1,4 @@ +import OpenClawKit import SwiftUI import WebKit @@ -22,13 +23,20 @@ struct TerminalHubScreen: View { var body: some View { let config = self.appModel.activeGatewayConnectConfig + let storedOperatorToken = config == nil ? nil : Self.storedOperatorToken() ZStack { OpenClawProBackground() if let url = Self.terminalURL(config: config) { - TerminalWebView(url: url, authScript: Self.terminalAuthUserScript(config: config)) + TerminalWebView( + url: url, + authScript: Self.terminalAuthUserScript( + config: config, + storedOperatorToken: storedOperatorToken)) // Recreate the web view only when the connection inputs // change; SwiftUI update passes must not restart live shells. - .id(Self.webContentIdentity(config: config)) + .id(Self.webContentIdentity( + config: config, + storedOperatorToken: storedOperatorToken)) .ignoresSafeArea(.container, edges: .bottom) } else { self.unavailableCard @@ -97,14 +105,24 @@ struct TerminalHubScreen: View { /// (the same mechanism the macOS Dashboard window uses), so the token never /// appears in the page URL, WebKit history, or gateway request logs. static func terminalAuthUserScript(config: GatewayConnectConfig?) -> String? { + self.terminalAuthUserScript(config: config, storedOperatorToken: self.storedOperatorToken()) + } + + static func terminalAuthUserScript( + config: GatewayConnectConfig?, + storedOperatorToken: String?) -> String? + { guard let config, let pageURL = terminalURL(config: config) else { return nil } var payload: [String: String] = ["gatewayUrl": config.url.absoluteString] let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let storedToken = storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !token.isEmpty { payload["token"] = token + } else if !storedToken.isEmpty { + payload["token"] = storedToken } if !password.isEmpty { payload["password"] = password @@ -134,13 +152,24 @@ struct TerminalHubScreen: View { /// Identity for the embedded web view: recreate it only when the gateway /// endpoint or credentials actually change. static func webContentIdentity(config: GatewayConnectConfig?) -> Int { + self.webContentIdentity(config: config, storedOperatorToken: self.storedOperatorToken()) + } + + static func webContentIdentity(config: GatewayConnectConfig?, storedOperatorToken: String?) -> Int { var hasher = Hasher() hasher.combine(config?.url) hasher.combine(config?.token) hasher.combine(config?.password) + hasher.combine(storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines)) return hasher.finalize() } + private static func storedOperatorToken() -> String? { + let identity = DeviceIdentityStore.loadOrCreate() + return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator")? + .token + } + static func originString(for url: URL) -> String { guard let scheme = url.scheme, let host = url.host else { return "" diff --git a/apps/ios/Tests/TerminalHubScreenTests.swift b/apps/ios/Tests/TerminalHubScreenTests.swift index b4c237db76d1..588008a9ca0d 100644 --- a/apps/ios/Tests/TerminalHubScreenTests.swift +++ b/apps/ios/Tests/TerminalHubScreenTests.swift @@ -64,10 +64,33 @@ struct TerminalHubScreenTests { #expect(script?.contains("\"gatewayUrl\":\"wss:\\/\\/gateway.example.com:8443\"") == true) } + @Test func `auth user script falls back to stored operator token`() throws { + let config = try Self.makeConfig( + url: #require(URL(string: "wss://gateway.example.com:8443")), + token: nil, + password: nil) + + let script = TerminalHubScreen.terminalAuthUserScript( + config: config, + storedOperatorToken: " stored-token ") + + #expect(script?.contains("\"token\":\"stored-token\"") == true) + } + + @Test func `web content identity changes with stored operator token`() throws { + let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com"))) + + #expect( + TerminalHubScreen.webContentIdentity(config: config, storedOperatorToken: "token-a") != + TerminalHubScreen.webContentIdentity(config: config, storedOperatorToken: "token-b")) + } + @Test func `auth user script is omitted without credentials`() throws { let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")), token: " ") - #expect(TerminalHubScreen.terminalAuthUserScript(config: config) == nil) - #expect(TerminalHubScreen.terminalAuthUserScript(config: nil) == nil) + #expect( + TerminalHubScreen.terminalAuthUserScript(config: config, storedOperatorToken: nil) == nil) + #expect( + TerminalHubScreen.terminalAuthUserScript(config: nil, storedOperatorToken: nil) == nil) } }