fix(mobile): authenticate terminal page with stored operator token

This commit is contained in:
Vincent Koc
2026-07-05 05:39:01 +02:00
parent 1c83c2d4a6
commit 451190d625
4 changed files with 156 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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