fix: switch pairing setup codes to bootstrap tokens

This commit is contained in:
Peter Steinberger
2026-03-12 22:22:44 +00:00
parent 9cd54ea882
commit bf89947a8e
53 changed files with 1035 additions and 106 deletions

View File

@@ -116,6 +116,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setGatewayToken(value)
}
fun setGatewayBootstrapToken(value: String) {
runtime.setGatewayBootstrapToken(value)
}
fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value)
}

View File

@@ -503,6 +503,7 @@ class NodeRuntime(context: Context) {
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value)
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
@@ -698,10 +699,25 @@ class NodeRuntime(context: Context) {
operatorStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword()
val tls = connectionManager.resolveTlsParams(endpoint)
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
operatorSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
nodeSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildNodeConnectOptions(),
tls,
)
operatorSession.reconnect()
nodeSession.reconnect()
}
@@ -726,9 +742,24 @@ class NodeRuntime(context: Context) {
nodeStatusText = "Connecting…"
updateStatus()
val token = prefs.loadGatewayToken()
val bootstrapToken = prefs.loadGatewayBootstrapToken()
val password = prefs.loadGatewayPassword()
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
operatorSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildOperatorConnectOptions(),
tls,
)
nodeSession.connect(
endpoint,
token,
bootstrapToken,
password,
connectionManager.buildNodeConnectOptions(),
tls,
)
}
fun acceptGatewayTrustPrompt() {

View File

@@ -15,7 +15,10 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
class SecurePrefs(context: Context) {
class SecurePrefs(
context: Context,
private val securePrefsOverride: SharedPreferences? = null,
) {
companion object {
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName"
@@ -35,7 +38,7 @@ class SecurePrefs(context: Context) {
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
private val securePrefs: SharedPreferences by lazy { securePrefsOverride ?: createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId
@@ -76,6 +79,9 @@ class SecurePrefs(context: Context) {
private val _gatewayToken = MutableStateFlow("")
val gatewayToken: StateFlow<String> = _gatewayToken
private val _gatewayBootstrapToken = MutableStateFlow("")
val gatewayBootstrapToken: StateFlow<String> = _gatewayBootstrapToken
private val _onboardingCompleted =
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
@@ -165,6 +171,10 @@ class SecurePrefs(context: Context) {
saveGatewayPassword(value)
}
fun setGatewayBootstrapToken(value: String) {
saveGatewayBootstrapToken(value)
}
fun setOnboardingCompleted(value: Boolean) {
plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value
@@ -193,6 +203,26 @@ class SecurePrefs(context: Context) {
securePrefs.edit { putString(key, token.trim()) }
}
fun loadGatewayBootstrapToken(): String? {
val key = "gateway.bootstrapToken.${_instanceId.value}"
val stored =
_gatewayBootstrapToken.value.trim().ifEmpty {
val persisted = securePrefs.getString(key, null)?.trim().orEmpty()
if (persisted.isNotEmpty()) {
_gatewayBootstrapToken.value = persisted
}
persisted
}
return stored.takeIf { it.isNotEmpty() }
}
fun saveGatewayBootstrapToken(token: String) {
val key = "gateway.bootstrapToken.${_instanceId.value}"
val trimmed = token.trim()
securePrefs.edit { putString(key, trimmed) }
_gatewayBootstrapToken.value = trimmed
}
fun loadGatewayPassword(): String? {
val key = "gateway.password.${_instanceId.value}"
val stored = securePrefs.getString(key, null)?.trim()

View File

@@ -95,6 +95,7 @@ class GatewaySession(
private data class DesiredConnection(
val endpoint: GatewayEndpoint,
val token: String?,
val bootstrapToken: String?,
val password: String?,
val options: GatewayConnectOptions,
val tls: GatewayTlsParams?,
@@ -107,11 +108,12 @@ class GatewaySession(
fun connect(
endpoint: GatewayEndpoint,
token: String?,
bootstrapToken: String?,
password: String?,
options: GatewayConnectOptions,
tls: GatewayTlsParams? = null,
) {
desired = DesiredConnection(endpoint, token, password, options, tls)
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
@@ -219,6 +221,7 @@ class GatewaySession(
private inner class Connection(
private val endpoint: GatewayEndpoint,
private val token: String?,
private val bootstrapToken: String?,
private val password: String?,
private val options: GatewayConnectOptions,
private val tls: GatewayTlsParams?,
@@ -346,9 +349,18 @@ class GatewaySession(
val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
val trimmedBootstrapToken = bootstrapToken?.trim().orEmpty()
// QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
val authBootstrapToken = if (authToken.isBlank()) trimmedBootstrapToken else ""
val payload =
buildConnectParams(
identity = identity,
connectNonce = connectNonce,
authToken = authToken,
authBootstrapToken = authBootstrapToken,
authPassword = password?.trim(),
)
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
if (!res.ok) {
val msg = res.error?.message ?: "connect failed"
@@ -381,6 +393,7 @@ class GatewaySession(
identity: DeviceIdentity,
connectNonce: String,
authToken: String,
authBootstrapToken: String,
authPassword: String?,
): JsonObject {
val client = options.client
@@ -404,6 +417,10 @@ class GatewaySession(
buildJsonObject {
put("token", JsonPrimitive(authToken))
}
authBootstrapToken.isNotEmpty() ->
buildJsonObject {
put("bootstrapToken", JsonPrimitive(authBootstrapToken))
}
password.isNotEmpty() ->
buildJsonObject {
put("password", JsonPrimitive(password))
@@ -420,7 +437,12 @@ class GatewaySession(
role = options.role,
scopes = options.scopes,
signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null,
token =
when {
authToken.isNotEmpty() -> authToken
authBootstrapToken.isNotEmpty() -> authBootstrapToken
else -> null
},
nonce = connectNonce,
platform = client.platform,
deviceFamily = client.deviceFamily,
@@ -622,7 +644,15 @@ class GatewaySession(
}
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls)
val conn =
Connection(
target.endpoint,
target.token,
target.bootstrapToken,
target.password,
target.options,
target.tls,
)
currentConnection = conn
try {
conn.connect()

View File

@@ -200,8 +200,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
viewModel.setManualHost(config.host)
viewModel.setManualPort(config.port)
viewModel.setManualTls(config.tls)
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
if (config.token.isNotBlank()) {
viewModel.setGatewayToken(config.token)
} else if (config.bootstrapToken.isNotBlank()) {
viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(config.password)
viewModel.connectManual()

View File

@@ -1,8 +1,8 @@
package ai.openclaw.app.ui
import androidx.core.net.toUri
import java.util.Base64
import java.util.Locale
import java.net.URI
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -18,6 +18,7 @@ internal data class GatewayEndpointConfig(
internal data class GatewaySetupCode(
val url: String,
val bootstrapToken: String?,
val token: String?,
val password: String?,
)
@@ -26,6 +27,7 @@ internal data class GatewayConnectConfig(
val host: String,
val port: Int,
val tls: Boolean,
val bootstrapToken: String,
val token: String,
val password: String,
)
@@ -44,12 +46,26 @@ internal fun resolveGatewayConnectConfig(
if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpoint(setup.url) ?: return null
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
val sharedToken =
when {
!setup.token.isNullOrBlank() -> setup.token.trim()
setupBootstrapToken.isNotEmpty() -> ""
else -> fallbackToken.trim()
}
val sharedPassword =
when {
!setup.password.isNullOrBlank() -> setup.password.trim()
setupBootstrapToken.isNotEmpty() -> ""
else -> fallbackPassword.trim()
}
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
token = setup.token ?: fallbackToken.trim(),
password = setup.password ?: fallbackPassword.trim(),
bootstrapToken = setupBootstrapToken,
token = sharedToken,
password = sharedPassword,
)
}
@@ -59,6 +75,7 @@ internal fun resolveGatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
bootstrapToken = "",
token = fallbackToken.trim(),
password = fallbackPassword.trim(),
)
@@ -69,7 +86,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
if (raw.isEmpty()) return null
val normalized = if (raw.contains("://")) raw else "https://$raw"
val uri = normalized.toUri()
val uri = runCatching { URI(normalized) }.getOrNull() ?: return null
val host = uri.host?.trim().orEmpty()
if (host.isEmpty()) return null
@@ -104,9 +121,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val obj = parseJsonObject(decoded) ?: return null
val url = jsonField(obj, "url").orEmpty()
if (url.isEmpty()) return null
val bootstrapToken = jsonField(obj, "bootstrapToken")
val token = jsonField(obj, "token")
val password = jsonField(obj, "password")
GatewaySetupCode(url = url, token = token, password = password)
GatewaySetupCode(url = url, bootstrapToken = bootstrapToken, token = token, password = password)
} catch (_: IllegalArgumentException) {
null
}

View File

@@ -772,8 +772,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
return@Button
}
gatewayUrl = parsedSetup.url
parsedSetup.token?.let { viewModel.setGatewayToken(it) }
gatewayPassword = parsedSetup.password.orEmpty()
viewModel.setGatewayBootstrapToken(parsedSetup.bootstrapToken.orEmpty())
val sharedToken = parsedSetup.token.orEmpty().trim()
val password = parsedSetup.password.orEmpty().trim()
if (sharedToken.isNotEmpty()) {
viewModel.setGatewayToken(sharedToken)
} else if (!parsedSetup.bootstrapToken.isNullOrBlank()) {
viewModel.setGatewayToken("")
}
gatewayPassword = password
if (password.isEmpty() && !parsedSetup.bootstrapToken.isNullOrBlank()) {
viewModel.setGatewayPassword("")
}
} else {
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
@@ -782,6 +792,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
return@Button
}
gatewayUrl = parsedGateway.displayUrl
viewModel.setGatewayBootstrapToken("")
}
step = OnboardingStep.Permissions
},
@@ -850,8 +861,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
viewModel.setManualHost(parsed.host)
viewModel.setManualPort(parsed.port)
viewModel.setManualTls(parsed.tls)
if (gatewayInputMode == GatewayInputMode.Manual) {
viewModel.setGatewayBootstrapToken("")
}
if (token.isNotEmpty()) {
viewModel.setGatewayToken(token)
} else {
viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(password)
viewModel.connectManual()

View File

@@ -20,4 +20,19 @@ class SecurePrefsTest {
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
}
@Test
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
val context = RuntimeEnvironment.getApplication()
val securePrefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE)
securePrefs.edit().clear().commit()
val prefs = SecurePrefs(context, securePrefsOverride = securePrefs)
prefs.setGatewayToken("shared-token")
prefs.setGatewayBootstrapToken("bootstrap-token")
assertEquals("shared-token", prefs.loadGatewayToken())
assertEquals("bootstrap-token", prefs.loadGatewayBootstrapToken())
assertEquals("bootstrap-token", prefs.gatewayBootstrapToken.value)
}
}

View File

@@ -46,6 +46,7 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
private data class NodeHarness(
val session: GatewaySession,
val sessionJob: Job,
val deviceAuthStore: InMemoryDeviceAuthStore,
)
private data class InvokeScenarioResult(
@@ -56,6 +57,93 @@ private data class InvokeScenarioResult(
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
@Test
fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() = runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val connectAuth = CompletableDeferred<JsonObject?>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
if (!connectAuth.isCompleted) {
connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject)
}
webSocket.send(connectResponseFrame(id))
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 auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() }
assertEquals("bootstrap-token", auth?.get("bootstrapToken")?.jsonPrimitive?.content)
assertNull(auth?.get("token"))
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun connect_prefersStoredDeviceTokenOverBootstrapToken() = runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val connectAuth = CompletableDeferred<JsonObject?>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
if (!connectAuth.isCompleted) {
connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject)
}
webSocket.send(connectResponseFrame(id))
webSocket.close(1000, "done")
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
harness.deviceAuthStore.saveToken(deviceId, "node", "device-token")
connectNodeSession(
session = harness.session,
port = server.port,
token = null,
bootstrapToken = "bootstrap-token",
)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() }
assertEquals("device-token", auth?.get("token")?.jsonPrimitive?.content)
assertNull(auth?.get("bootstrapToken"))
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
val handshakeOrigin = AtomicReference<String?>(null)
@@ -182,11 +270,12 @@ class GatewaySessionInvokeTest {
): NodeHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = InMemoryDeviceAuthStore(),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
@@ -197,10 +286,15 @@ class GatewaySessionInvokeTest {
onInvoke = onInvoke,
)
return NodeHarness(session = session, sessionJob = sessionJob)
return NodeHarness(session = session, sessionJob = sessionJob, deviceAuthStore = deviceAuthStore)
}
private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
private suspend fun connectNodeSession(
session: GatewaySession,
port: Int,
token: String? = "test-token",
bootstrapToken: String? = null,
) {
session.connect(
endpoint =
GatewayEndpoint(
@@ -210,7 +304,8 @@ class GatewaySessionInvokeTest {
port = port,
tlsEnabled = false,
),
token = "test-token",
token = token,
bootstrapToken = bootstrapToken,
password = null,
options =
GatewayConnectOptions(

View File

@@ -8,7 +8,8 @@ import org.junit.Test
class GatewayConfigResolverTest {
@Test
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""")
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
@@ -17,7 +18,8 @@ class GatewayConfigResolverTest {
@Test
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""")
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val qrJson =
"""
{
@@ -53,6 +55,43 @@ class GatewayConfigResolverTest {
assertNull(resolved)
}
@Test
fun decodeGatewaySetupCodeParsesBootstrapToken() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val decoded = decodeGatewaySetupCode(setupCode)
assertEquals("wss://gateway.example:18789", decoded?.url)
assertEquals("bootstrap-1", decoded?.bootstrapToken)
assertNull(decoded?.token)
assertNull(decoded?.password)
}
@Test
fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved =
resolveGatewayConnectConfig(
useSetupCode = true,
setupCode = setupCode,
manualHost = "",
manualPort = "",
manualTls = true,
fallbackToken = "shared-token",
fallbackPassword = "shared-password",
)
assertEquals("gateway.example", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(true, resolved?.tls)
assertEquals("bootstrap-1", resolved?.bootstrapToken)
assertNull(resolved?.token?.takeIf { it.isNotEmpty() })
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
}
private fun encodeSetupCode(payloadJson: String): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
}

View File

@@ -14,6 +14,7 @@ struct GatewayConnectConfig: Sendable {
let stableID: String
let tls: GatewayTLSParams?
let token: String?
let bootstrapToken: String?
let password: String?
let nodeOptions: GatewayConnectOptions

View File

@@ -101,6 +101,7 @@ final class GatewayConnectionController {
return "Missing instanceId (node.instanceId). Try restarting the app."
}
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
@@ -151,6 +152,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID,
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
return nil
}
@@ -163,6 +165,7 @@ final class GatewayConnectionController {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
@@ -203,6 +206,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID,
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
}
@@ -229,6 +233,7 @@ final class GatewayConnectionController {
stableID: cfg.stableID,
tls: cfg.tls,
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
appModel.applyGatewayConnectConfig(refreshedConfig)
@@ -261,6 +266,7 @@ final class GatewayConnectionController {
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let tlsParams = GatewayTLSParams(
required: true,
@@ -274,6 +280,7 @@ final class GatewayConnectionController {
gatewayStableID: pending.stableID,
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
}
@@ -319,6 +326,7 @@ final class GatewayConnectionController {
guard !instanceId.isEmpty else { return }
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
if manualEnabled {
@@ -353,6 +361,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID,
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
return
}
@@ -379,6 +388,7 @@ final class GatewayConnectionController {
gatewayStableID: stableID,
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
return
}
@@ -448,6 +458,7 @@ final class GatewayConnectionController {
gatewayStableID: String,
tls: GatewayTLSParams?,
token: String?,
bootstrapToken: String?,
password: String?)
{
guard let appModel else { return }
@@ -463,6 +474,7 @@ final class GatewayConnectionController {
stableID: gatewayStableID,
tls: tls,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions)
appModel.applyGatewayConnectConfig(cfg)

View File

@@ -104,6 +104,21 @@ enum GatewaySettingsStore {
account: self.gatewayTokenAccount(instanceId: instanceId))
}
static func loadGatewayBootstrapToken(instanceId: String) -> String? {
let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if token?.isEmpty == false { return token }
return nil
}
static func saveGatewayBootstrapToken(_ token: String, instanceId: String) {
_ = KeychainStore.saveString(
token,
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString(
service: self.gatewayService,
@@ -278,6 +293,9 @@ enum GatewaySettingsStore {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: trimmed))
@@ -331,6 +349,10 @@ enum GatewaySettingsStore {
"gateway-token.\(instanceId)"
}
private static func gatewayBootstrapTokenAccount(instanceId: String) -> String {
"gateway-bootstrap-token.\(instanceId)"
}
private static func gatewayPasswordAccount(instanceId: String) -> String {
"gateway-password.\(instanceId)"
}

View File

@@ -5,6 +5,7 @@ struct GatewaySetupPayload: Codable {
var host: String?
var port: Int?
var tls: Bool?
var bootstrapToken: String?
var token: String?
var password: String?
}
@@ -39,4 +40,3 @@ enum GatewaySetupCode {
return String(data: data, encoding: .utf8)
}
}

View File

@@ -1680,6 +1680,7 @@ extension NodeAppModel {
gatewayStableID: String,
tls: GatewayTLSParams?,
token: String?,
bootstrapToken: String?,
password: String?,
connectOptions: GatewayConnectOptions)
{
@@ -1692,6 +1693,7 @@ extension NodeAppModel {
stableID: stableID,
tls: tls,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions)
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
@@ -1699,6 +1701,7 @@ extension NodeAppModel {
url: url,
stableID: effectiveStableID,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
@@ -1706,6 +1709,7 @@ extension NodeAppModel {
url: url,
stableID: effectiveStableID,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
@@ -1721,6 +1725,7 @@ extension NodeAppModel {
gatewayStableID: cfg.stableID,
tls: cfg.tls,
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
connectOptions: cfg.nodeOptions)
}
@@ -1801,6 +1806,7 @@ private extension NodeAppModel {
url: URL,
stableID: String,
token: String?,
bootstrapToken: String?,
password: String?,
nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?)
@@ -1838,6 +1844,7 @@ private extension NodeAppModel {
try await self.operatorGateway.connect(
url: url,
token: token,
bootstrapToken: bootstrapToken,
password: password,
connectOptions: operatorOptions,
sessionBox: sessionBox,
@@ -1896,6 +1903,7 @@ private extension NodeAppModel {
url: URL,
stableID: String,
token: String?,
bootstrapToken: String?,
password: String?,
nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?)
@@ -1944,6 +1952,7 @@ private extension NodeAppModel {
try await self.nodeGateway.connect(
url: url,
token: token,
bootstrapToken: bootstrapToken,
password: password,
connectOptions: currentOptions,
sessionBox: sessionBox,

View File

@@ -275,9 +275,21 @@ private struct ManualEntryStep: View {
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
} else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
self.manualToken = ""
}
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
} else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
self.manualPassword = ""
}
let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedInstanceId.isEmpty {
let trimmedBootstrapToken =
payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId)
}
self.setupStatusText = "Setup code applied."

View File

@@ -642,11 +642,17 @@ struct OnboardingWizardView: View {
self.manualHost = link.host
self.manualPort = link.port
self.manualTLS = link.tls
if let token = link.token {
let trimmedBootstrapToken = link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
self.saveGatewayBootstrapToken(trimmedBootstrapToken)
if let token = link.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
self.gatewayToken = token
} else if trimmedBootstrapToken?.isEmpty == false {
self.gatewayToken = ""
}
if let password = link.password {
if let password = link.password?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty {
self.gatewayPassword = password
} else if trimmedBootstrapToken?.isEmpty == false {
self.gatewayPassword = ""
}
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
self.showQRScanner = false
@@ -794,6 +800,13 @@ struct OnboardingWizardView: View {
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
}
private func saveGatewayBootstrapToken(_ token: String?) {
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInstanceId.isEmpty else { return }
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedToken, instanceId: trimmedInstanceId)
}
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id
self.issue = .none

View File

@@ -767,12 +767,22 @@ struct SettingsTab: View {
}
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedBootstrapToken =
payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId)
}
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
self.gatewayToken = trimmedToken
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
}
} else if !trimmedBootstrapToken.isEmpty {
self.gatewayToken = ""
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayToken("", instanceId: trimmedInstanceId)
}
}
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -780,6 +790,11 @@ struct SettingsTab: View {
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
}
} else if !trimmedBootstrapToken.isEmpty {
self.gatewayPassword = ""
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId)
}
}
return true

View File

@@ -86,7 +86,13 @@ private func agentAction(
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
#expect(
DeepLinkParser.parse(url) == .gateway(
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
.init(
host: "openclaw.local",
port: 18789,
tls: true,
bootstrapToken: nil,
token: "abc",
password: "def")))
}
@Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() {
@@ -102,14 +108,15 @@ private func agentAction(
}
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
token: "tok",
bootstrapToken: "tok",
token: nil,
password: "pw"))
}
@@ -118,38 +125,40 @@ private func agentAction(
}
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
token: "tok",
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "127.0.0.1",
port: 18789,
tls: false,
token: "tok",
bootstrapToken: "tok",
token: nil,
password: nil))
}
}

View File

@@ -324,6 +324,8 @@ final class ControlChannel {
switch source {
case .deviceToken:
return "Auth: device token (paired device)"
case .bootstrapToken:
return "Auth: bootstrap token (setup code)"
case .sharedToken:
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
case .password:

View File

@@ -77,6 +77,7 @@ final class MacNodeModeCoordinator {
try await self.session.connect(
url: config.url,
token: config.token,
bootstrapToken: nil,
password: config.password,
connectOptions: connectOptions,
sessionBox: sessionBox,

View File

@@ -508,6 +508,8 @@ extension OnboardingView {
return ("exclamationmark.triangle.fill", .orange)
case .gatewayTokenNotConfigured:
return ("wrench.and.screwdriver.fill", .orange)
case .setupCodeExpired:
return ("qrcode.viewfinder", .orange)
case .passwordRequired:
return ("lock.slash.fill", .orange)
case .pairingRequired:

View File

@@ -6,6 +6,7 @@ enum RemoteGatewayAuthIssue: Equatable {
case tokenRequired
case tokenMismatch
case gatewayTokenNotConfigured
case setupCodeExpired
case passwordRequired
case pairingRequired
@@ -20,6 +21,8 @@ enum RemoteGatewayAuthIssue: Equatable {
self = .tokenMismatch
case .authTokenNotConfigured:
self = .gatewayTokenNotConfigured
case .authBootstrapTokenInvalid:
self = .setupCodeExpired
case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
self = .passwordRequired
case .pairingRequired:
@@ -33,7 +36,7 @@ enum RemoteGatewayAuthIssue: Equatable {
switch self {
case .tokenRequired, .tokenMismatch:
true
case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired:
case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired:
false
}
}
@@ -46,6 +49,8 @@ enum RemoteGatewayAuthIssue: Equatable {
"That token did not match the gateway"
case .gatewayTokenNotConfigured:
"This gateway host needs token setup"
case .setupCodeExpired:
"This setup code is no longer valid"
case .passwordRequired:
"This gateway is using unsupported auth"
case .pairingRequired:
@@ -61,6 +66,8 @@ enum RemoteGatewayAuthIssue: Equatable {
"Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
case .gatewayTokenNotConfigured:
"This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway."
case .setupCodeExpired:
"Scan or paste a fresh setup code from an already-paired OpenClaw client, then try again."
case .passwordRequired:
"This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
case .pairingRequired:
@@ -72,6 +79,8 @@ enum RemoteGatewayAuthIssue: Equatable {
switch self {
case .tokenRequired, .gatewayTokenNotConfigured:
"No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`."
case .setupCodeExpired:
nil
case .pairingRequired:
"If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`."
case .tokenMismatch, .passwordRequired:
@@ -87,6 +96,8 @@ enum RemoteGatewayAuthIssue: Equatable {
"Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host."
case .gatewayTokenNotConfigured:
"This gateway has token auth enabled, but no gateway.auth.token is configured on the host."
case .setupCodeExpired:
"Setup code expired or already used. Scan a fresh setup code, then try again."
case .passwordRequired:
"This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
case .pairingRequired:
@@ -108,6 +119,8 @@ struct RemoteGatewayProbeSuccess: Equatable {
switch self.authSource {
case .some(.deviceToken):
"Connected via paired device"
case .some(.bootstrapToken):
"Connected with setup code"
case .some(.sharedToken):
"Connected with gateway token"
case .some(.password):
@@ -121,6 +134,8 @@ struct RemoteGatewayProbeSuccess: Equatable {
switch self.authSource {
case .some(.deviceToken):
"This Mac used a stored device token. New or unpaired devices may still need the gateway token."
case .some(.bootstrapToken):
"This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth."
case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil:
nil
}

View File

@@ -17,6 +17,10 @@ struct OnboardingRemoteAuthPromptTests {
message: "token not configured",
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
canRetryWithDeviceToken: false)
let bootstrapInvalid = GatewayConnectAuthError(
message: "setup code expired",
detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
canRetryWithDeviceToken: false)
let passwordMissing = GatewayConnectAuthError(
message: "password missing",
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
@@ -33,6 +37,7 @@ struct OnboardingRemoteAuthPromptTests {
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
#expect(RemoteGatewayAuthIssue(error: bootstrapInvalid) == .setupCodeExpired)
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
#expect(RemoteGatewayAuthIssue(error: unknown) == nil)
@@ -88,6 +93,11 @@ struct OnboardingRemoteAuthPromptTests {
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: .gatewayTokenNotConfigured) == false)
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
remoteTokenUnsupported: false,
authIssue: .setupCodeExpired) == false)
#expect(OnboardingView.shouldShowRemoteTokenField(
showAdvancedConnection: false,
remoteToken: "",
@@ -106,11 +116,14 @@ struct OnboardingRemoteAuthPromptTests {
@Test func `paired device success copy explains auth source`() {
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken)
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
#expect(pairedDevice.title == "Connected via paired device")
#expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.")
#expect(bootstrap.title == "Connected with setup code")
#expect(bootstrap.detail == "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth.")
#expect(sharedToken.title == "Connected with gateway token")
#expect(sharedToken.detail == nil)
#expect(noAuth.title == "Remote gateway ready")

View File

@@ -9,13 +9,15 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let host: String
public let port: Int
public let tls: Bool
public let bootstrapToken: String?
public let token: String?
public let password: String?
public init(host: String, port: Int, tls: Bool, token: String?, password: String?) {
public init(host: String, port: Int, tls: Bool, bootstrapToken: String?, token: String?, password: String?) {
self.host = host
self.port = port
self.tls = tls
self.bootstrapToken = bootstrapToken
self.token = token
self.password = password
}
@@ -25,7 +27,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
return URL(string: "\(scheme)://\(self.host):\(self.port)")
}
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`).
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`).
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
guard let data = Self.decodeBase64Url(code) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
@@ -41,9 +43,16 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
return nil
}
let port = parsed.port ?? (tls ? 443 : 18789)
let bootstrapToken = json["bootstrapToken"] as? String
let token = json["token"] as? String
let password = json["password"] as? String
return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password)
return GatewayConnectDeepLink(
host: hostname,
port: port,
tls: tls,
bootstrapToken: bootstrapToken,
token: token,
password: password)
}
private static func decodeBase64Url(_ input: String) -> Data? {
@@ -140,6 +149,7 @@ public enum DeepLinkParser {
host: hostParam,
port: port,
tls: tls,
bootstrapToken: nil,
token: query["token"],
password: query["password"]))

View File

@@ -112,6 +112,7 @@ public struct GatewayConnectOptions: Sendable {
public enum GatewayAuthSource: String, Sendable {
case deviceToken = "device-token"
case sharedToken = "shared-token"
case bootstrapToken = "bootstrap-token"
case password = "password"
case none = "none"
}
@@ -131,6 +132,12 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing",
]
private extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
private enum GatewayConnectErrorCodes {
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
@@ -154,6 +161,7 @@ public actor GatewayChannelActor {
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
private var url: URL
private var token: String?
private var bootstrapToken: String?
private var password: String?
private let session: WebSocketSessioning
private var backoffMs: Double = 500
@@ -185,6 +193,7 @@ public actor GatewayChannelActor {
public init(
url: URL,
token: String?,
bootstrapToken: String? = nil,
password: String? = nil,
session: WebSocketSessionBox? = nil,
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
@@ -193,6 +202,7 @@ public actor GatewayChannelActor {
{
self.url = url
self.token = token
self.bootstrapToken = bootstrapToken
self.password = password
self.session = session?.session ?? URLSession(configuration: .default)
self.pushHandler = pushHandler
@@ -402,22 +412,29 @@ public actor GatewayChannelActor {
(includeDeviceIdentity && identity != nil)
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
: nil
let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let explicitBootstrapToken =
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let shouldUseDeviceRetryToken =
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint()
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
if shouldUseDeviceRetryToken {
self.pendingDeviceTokenRetry = false
}
// Keep shared credentials explicit when provided. Device token retry is attached
// only on a bounded second attempt after token mismatch.
let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil)
let authToken = explicitToken ?? (includeDeviceIdentity ? storedToken : nil)
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource
if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
if authDeviceToken != nil || (explicitToken == nil && storedToken != nil) {
authSource = .deviceToken
} else if authToken != nil {
authSource = .sharedToken
} else if self.password != nil {
} else if authBootstrapToken != nil {
authSource = .bootstrapToken
} else if explicitPassword != nil {
authSource = .password
} else {
authSource = .none
@@ -430,7 +447,9 @@ public actor GatewayChannelActor {
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
}
params["auth"] = ProtoAnyCodable(auth)
} else if let password = self.password {
} else if let authBootstrapToken {
params["auth"] = ProtoAnyCodable(["bootstrapToken": ProtoAnyCodable(authBootstrapToken)])
} else if let password = explicitPassword {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
@@ -443,7 +462,7 @@ public actor GatewayChannelActor {
role: role,
scopes: scopes,
signedAtMs: signedAtMs,
token: authToken,
token: authToken ?? authBootstrapToken,
nonce: connectNonce,
platform: platform,
deviceFamily: InstanceIdentity.deviceFamily)
@@ -472,7 +491,7 @@ public actor GatewayChannelActor {
} catch {
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
error: error,
explicitGatewayToken: self.token,
explicitGatewayToken: explicitToken,
storedToken: storedToken,
attemptedDeviceTokenRetry: authDeviceToken != nil)
if shouldRetryWithDeviceToken {

View File

@@ -5,6 +5,7 @@ public enum GatewayConnectAuthDetailCode: String, Sendable {
case authRequired = "AUTH_REQUIRED"
case authUnauthorized = "AUTH_UNAUTHORIZED"
case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
case authBootstrapTokenInvalid = "AUTH_BOOTSTRAP_TOKEN_INVALID"
case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
case authTokenMissing = "AUTH_TOKEN_MISSING"
case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
@@ -92,6 +93,7 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
public var isNonRecoverable: Bool {
switch self.detail {
case .authTokenMissing,
.authBootstrapTokenInvalid,
.authTokenNotConfigured,
.authPasswordMissing,
.authPasswordMismatch,

View File

@@ -64,6 +64,7 @@ public actor GatewayNodeSession {
private var channel: GatewayChannelActor?
private var activeURL: URL?
private var activeToken: String?
private var activeBootstrapToken: String?
private var activePassword: String?
private var activeConnectOptionsKey: String?
private var connectOptions: GatewayConnectOptions?
@@ -194,6 +195,7 @@ public actor GatewayNodeSession {
public func connect(
url: URL,
token: String?,
bootstrapToken: String?,
password: String?,
connectOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?,
@@ -204,6 +206,7 @@ public actor GatewayNodeSession {
let nextOptionsKey = self.connectOptionsKey(connectOptions)
let shouldReconnect = self.activeURL != url ||
self.activeToken != token ||
self.activeBootstrapToken != bootstrapToken ||
self.activePassword != password ||
self.activeConnectOptionsKey != nextOptionsKey ||
self.channel == nil
@@ -221,6 +224,7 @@ public actor GatewayNodeSession {
let channel = GatewayChannelActor(
url: url,
token: token,
bootstrapToken: bootstrapToken,
password: password,
session: sessionBox,
pushHandler: { [weak self] push in
@@ -233,6 +237,7 @@ public actor GatewayNodeSession {
self.channel = channel
self.activeURL = url
self.activeToken = token
self.activeBootstrapToken = bootstrapToken
self.activePassword = password
self.activeConnectOptionsKey = nextOptionsKey
}
@@ -257,6 +262,7 @@ public actor GatewayNodeSession {
self.channel = nil
self.activeURL = nil
self.activeToken = nil
self.activeBootstrapToken = nil
self.activePassword = nil
self.activeConnectOptionsKey = nil
self.hasEverConnected = false

View File

@@ -20,11 +20,17 @@ import Testing
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
#expect(
DeepLinkParser.parse(url) == .gateway(
.init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil)))
.init(
host: "127.0.0.1",
port: 18789,
tls: false,
bootstrapToken: nil,
token: "abc",
password: nil)))
}
@Test func setupCodeRejectsInsecureNonLoopbackWs() {
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -34,7 +40,7 @@ import Testing
}
@Test func setupCodeRejectsInsecurePrefixBypassHost() {
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -44,7 +50,7 @@ import Testing
}
@Test func setupCodeAllowsLoopbackWs() {
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
@@ -55,7 +61,8 @@ import Testing
host: "127.0.0.1",
port: 18789,
tls: false,
token: "tok",
bootstrapToken: "tok",
token: nil,
password: nil))
}
}

View File

@@ -0,0 +1,14 @@
import OpenClawKit
import Testing
@Suite struct GatewayErrorsTests {
@Test func bootstrapTokenInvalidIsNonRecoverable() {
let error = GatewayConnectAuthError(
message: "setup code expired",
detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
canRetryWithDeviceToken: false)
#expect(error.isNonRecoverable)
#expect(error.detail == .authBootstrapTokenInvalid)
}
}

View File

@@ -266,6 +266,7 @@ struct GatewayNodeSessionTests {
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),