fix: switch pairing setup codes to bootstrap tokens

This commit is contained in:
Peter Steinberger 2026-03-12 22:22:44 +00:00 committed by Vincent Koc
parent 4ca84acf24
commit 0aa1c69a79
53 changed files with 1035 additions and 106 deletions

View File

@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.
- Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.
- Security/device pairing: switch `/pair` and `openclaw qr` setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.
- Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (`GHSA-99qw-6mr3-36qr`)(#44174) Thanks @lintsinghua and @vincentkoc.
- Moonshot CN API: respect explicit `baseUrl` (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.
- Kimi Coding/provider config: respect explicit `models.providers["kimi-coding"].baseUrl` when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.

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),

View File

@ -72,7 +72,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire
The setup code is a base64-encoded JSON payload that contains:
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`)
- `token`: a short-lived pairing token
- `bootstrapToken`: a short-lived single-device bootstrap token used for the initial pairing handshake
Treat the setup code like a password while it is valid.

View File

@ -17,7 +17,7 @@ openclaw qr
openclaw qr --setup-code-only
openclaw qr --json
openclaw qr --remote
openclaw qr --url wss://gateway.example/ws --token '<token>'
openclaw qr --url wss://gateway.example/ws
```
## Options
@ -25,8 +25,8 @@ openclaw qr --url wss://gateway.example/ws --token '<token>'
- `--remote`: use `gateway.remote.url` plus remote token/password from config
- `--url <url>`: override gateway URL used in payload
- `--public-url <url>`: override public URL used in payload
- `--token <token>`: override gateway token for payload
- `--password <password>`: override gateway password for payload
- `--token <token>`: override which gateway token the bootstrap flow authenticates against
- `--password <password>`: override which gateway password the bootstrap flow authenticates against
- `--setup-code-only`: print only setup code
- `--no-ascii`: skip ASCII QR rendering
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`)
@ -34,6 +34,7 @@ openclaw qr --url wss://gateway.example/ws --token '<token>'
## Notes
- `--token` and `--password` are mutually exclusive.
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.
- Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed:
- `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins).

View File

@ -2,6 +2,7 @@ import os from "node:os";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair";
import {
approveDevicePairing,
issueDeviceBootstrapToken,
listDevicePairing,
resolveGatewayBindUrl,
runPluginCommandWithTimeout,
@ -31,8 +32,7 @@ type DevicePairPluginConfig = {
type SetupPayload = {
url: string;
token?: string;
password?: string;
bootstrapToken: string;
};
type ResolveUrlResult = {
@ -405,8 +405,14 @@ export default function register(api: OpenClawPluginApi) {
const payload: SetupPayload = {
url: urlResult.url,
token: auth.token,
password: auth.password,
bootstrapToken: (
await issueDeviceBootstrapToken({
channel: ctx.channel,
senderId: ctx.senderId ?? ctx.from ?? ctx.to,
accountId: ctx.accountId,
threadId: ctx.messageThreadId != null ? String(ctx.messageThreadId) : undefined,
})
).token,
};
if (action === "qr") {

View File

@ -27,6 +27,12 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWi
vi.mock("./command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
}));
vi.mock("../infra/device-bootstrap.js", () => ({
issueDeviceBootstrapToken: vi.fn(async () => ({
token: "bootstrap-123",
expiresAtMs: 123,
})),
}));
vi.mock("qrcode-terminal", () => ({
default: {
generate: mocks.qrGenerate,
@ -156,7 +162,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
token: "tok",
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(qrGenerate).not.toHaveBeenCalled();
@ -194,7 +200,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
token: "override-token",
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
});
@ -210,7 +216,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
token: "override-token",
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
});
@ -227,7 +233,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
password: "local-password-secret", // pragma: allowlist secret
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
@ -245,7 +251,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
password: "password-from-env", // pragma: allowlist secret
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
@ -264,7 +270,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
token: "token-123",
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
@ -282,7 +288,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
password: "inferred-password", // pragma: allowlist secret
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
@ -332,7 +338,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444",
token: "remote-tok",
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
@ -375,7 +381,7 @@ describe("registerQrCli", () => {
).toBe(true);
const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444",
token: "remote-tok",
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
});

View File

@ -66,12 +66,22 @@ function createGatewayTokenRefFixture() {
};
}
function decodeSetupCode(setupCode: string): { url?: string; token?: string; password?: string } {
function decodeSetupCode(setupCode: string): {
url?: string;
bootstrapToken?: string;
token?: string;
password?: string;
} {
const padded = setupCode.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (padded.length % 4)) % 4;
const normalized = padded + "=".repeat(padLength);
const json = Buffer.from(normalized, "base64").toString("utf8");
return JSON.parse(json) as { url?: string; token?: string; password?: string };
return JSON.parse(json) as {
url?: string;
bootstrapToken?: string;
token?: string;
password?: string;
};
}
async function runCli(args: string[]): Promise<void> {
@ -126,7 +136,8 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
expect(setupCode).toBeTruthy();
const payload = decodeSetupCode(setupCode ?? "");
expect(payload.url).toBe("ws://gateway.local:18789");
expect(payload.token).toBe("shared-token-123");
expect(payload.bootstrapToken).toBeTruthy();
expect(payload.token).toBeUndefined();
expect(runtimeErrors).toEqual([]);
runtimeLogs.length = 0;

View File

@ -39,7 +39,14 @@ export type ResolvedGatewayAuth = {
export type GatewayAuthResult = {
ok: boolean;
method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy";
method?:
| "none"
| "token"
| "password"
| "tailscale"
| "device-token"
| "bootstrap-token"
| "trusted-proxy";
user?: string;
reason?: string;
/** Present when the request was blocked by the rate limiter. */

View File

@ -335,6 +335,7 @@ describe("GatewayClient connect auth payload", () => {
params?: {
auth?: {
token?: string;
bootstrapToken?: string;
deviceToken?: string;
password?: string;
};
@ -410,6 +411,26 @@ describe("GatewayClient connect auth payload", () => {
client.stop();
});
it("uses bootstrap token when no shared or device token is available", () => {
loadDeviceAuthTokenMock.mockReturnValue(undefined);
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
bootstrapToken: "bootstrap-token",
});
client.start();
const ws = getLatestWs();
ws.emitOpen();
emitConnectChallenge(ws);
expect(connectFrameFrom(ws)).toMatchObject({
bootstrapToken: "bootstrap-token",
});
expect(connectFrameFrom(ws).token).toBeUndefined();
expect(connectFrameFrom(ws).deviceToken).toBeUndefined();
client.stop();
});
it("prefers explicit deviceToken over stored device token", () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({

View File

@ -69,6 +69,7 @@ export type GatewayClientOptions = {
connectDelayMs?: number;
tickWatchMinIntervalMs?: number;
token?: string;
bootstrapToken?: string;
deviceToken?: string;
password?: string;
instanceId?: string;
@ -281,6 +282,7 @@ export class GatewayClient {
}
const role = this.opts.role ?? "operator";
const explicitGatewayToken = this.opts.token?.trim() || undefined;
const explicitBootstrapToken = this.opts.bootstrapToken?.trim() || undefined;
const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined;
const storedToken = this.opts.deviceIdentity
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
@ -294,21 +296,27 @@ export class GatewayClient {
if (shouldUseDeviceRetryToken) {
this.pendingDeviceTokenRetry = false;
}
// Keep shared gateway credentials explicit. Persisted per-device tokens only
// participate when no explicit shared token/password is provided.
// Shared gateway credentials stay explicit. Bootstrap tokens are different:
// once a role-scoped device token exists, it should take precedence so the
// temporary bootstrap secret falls out of active use.
const resolvedDeviceToken =
explicitDeviceToken ??
(shouldUseDeviceRetryToken || !(explicitGatewayToken || this.opts.password?.trim())
(shouldUseDeviceRetryToken ||
(!(explicitGatewayToken || this.opts.password?.trim()) &&
(!explicitBootstrapToken || Boolean(storedToken)))
? (storedToken ?? undefined)
: undefined);
// Legacy compatibility: keep `auth.token` populated for device-token auth when
// no explicit shared token is present.
const authToken = explicitGatewayToken ?? resolvedDeviceToken;
const authBootstrapToken =
!explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined;
const authPassword = this.opts.password?.trim() || undefined;
const auth =
authToken || authPassword || resolvedDeviceToken
authToken || authBootstrapToken || authPassword || resolvedDeviceToken
? {
token: authToken,
bootstrapToken: authBootstrapToken,
deviceToken: resolvedDeviceToken,
password: authPassword,
}
@ -327,7 +335,7 @@ export class GatewayClient {
role,
scopes,
signedAtMs,
token: authToken ?? null,
token: authToken ?? authBootstrapToken ?? null,
nonce,
platform,
deviceFamily: this.opts.deviceFamily,
@ -420,6 +428,7 @@ export class GatewayClient {
}
if (
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||

View File

@ -7,6 +7,7 @@ export const ConnectErrorDetailCodes = {
AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING", // pragma: allowlist secret
AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH", // pragma: allowlist secret
AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED", // pragma: allowlist secret
AUTH_BOOTSTRAP_TOKEN_INVALID: "AUTH_BOOTSTRAP_TOKEN_INVALID",
AUTH_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH",
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
@ -64,6 +65,8 @@ export function resolveAuthConnectErrorDetailCode(
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH;
case "password_missing_config":
return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED;
case "bootstrap_token_invalid":
return ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID;
case "tailscale_user_missing":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING;
case "tailscale_proxy_missing":

View File

@ -56,6 +56,7 @@ export const ConnectParamsSchema = Type.Object(
Type.Object(
{
token: Type.Optional(Type.String()),
bootstrapToken: Type.Optional(Type.String()),
deviceToken: Type.Optional(Type.String()),
password: Type.Optional(Type.String()),
},

View File

@ -21,6 +21,12 @@ describe("isNonRecoverableAuthError", () => {
);
});
it("blocks reconnect for AUTH_BOOTSTRAP_TOKEN_INVALID", () => {
expect(
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID)),
).toBe(true);
});
it("blocks reconnect for AUTH_PASSWORD_MISSING", () => {
expect(
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)),

View File

@ -3,6 +3,9 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
type VerifyDeviceTokenFn = Parameters<typeof resolveConnectAuthDecision>[0]["verifyDeviceToken"];
type VerifyBootstrapTokenFn = Parameters<
typeof resolveConnectAuthDecision
>[0]["verifyBootstrapToken"];
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
limiter: AuthRateLimiter;
@ -38,6 +41,7 @@ function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthStat
async function resolveDeviceTokenDecision(params: {
verifyDeviceToken: VerifyDeviceTokenFn;
verifyBootstrapToken?: VerifyBootstrapTokenFn;
stateOverrides?: Partial<ConnectAuthState>;
rateLimiter?: AuthRateLimiter;
clientIp?: string;
@ -46,8 +50,12 @@ async function resolveDeviceTokenDecision(params: {
state: createBaseState(params.stateOverrides),
hasDeviceIdentity: true,
deviceId: "dev-1",
publicKey: "pub-1",
role: "operator",
scopes: ["operator.read"],
verifyBootstrapToken:
params.verifyBootstrapToken ??
(async () => ({ ok: false, reason: "bootstrap_token_invalid" })),
verifyDeviceToken: params.verifyDeviceToken,
...(params.rateLimiter ? { rateLimiter: params.rateLimiter } : {}),
...(params.clientIp ? { clientIp: params.clientIp } : {}),
@ -57,16 +65,23 @@ async function resolveDeviceTokenDecision(params: {
describe("resolveConnectAuthDecision", () => {
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false }));
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
ok: false,
reason: "bootstrap_token_invalid",
}));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
publicKey: "pub-1",
role: "operator",
scopes: ["operator.read"],
verifyBootstrapToken,
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("token_mismatch");
expect(verifyBootstrapToken).not.toHaveBeenCalled();
expect(verifyDeviceToken).toHaveBeenCalledOnce();
});
@ -78,8 +93,10 @@ describe("resolveConnectAuthDecision", () => {
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
publicKey: "pub-1",
role: "operator",
scopes: ["operator.read"],
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
@ -100,6 +117,44 @@ describe("resolveConnectAuthDecision", () => {
expect(rateLimiter.reset).toHaveBeenCalledOnce();
});
it("accepts valid bootstrap tokens before device-token fallback", async () => {
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({ ok: true }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveDeviceTokenDecision({
verifyBootstrapToken,
verifyDeviceToken,
stateOverrides: {
bootstrapTokenCandidate: "bootstrap-token",
deviceTokenCandidate: "device-token",
},
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("bootstrap-token");
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("reports invalid bootstrap tokens when no device token fallback is available", async () => {
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
ok: false,
reason: "bootstrap_token_invalid",
}));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveDeviceTokenDecision({
verifyBootstrapToken,
verifyDeviceToken,
stateOverrides: {
bootstrapTokenCandidate: "bootstrap-token",
deviceTokenCandidate: undefined,
deviceTokenCandidateSource: undefined,
},
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("bootstrap_token_invalid");
expect(verifyBootstrapToken).toHaveBeenCalledOnce();
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("returns rate-limited auth result without verifying device token", async () => {
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
@ -123,8 +178,10 @@ describe("resolveConnectAuthDecision", () => {
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
publicKey: "pub-1",
role: "operator",
scopes: [],
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);

View File

@ -14,6 +14,7 @@ import {
type HandshakeConnectAuth = {
token?: string;
bootstrapToken?: string;
deviceToken?: string;
password?: string;
};
@ -26,11 +27,13 @@ export type ConnectAuthState = {
authMethod: GatewayAuthResult["method"];
sharedAuthOk: boolean;
sharedAuthProvided: boolean;
bootstrapTokenCandidate?: string;
deviceTokenCandidate?: string;
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
};
type VerifyDeviceTokenResult = { ok: boolean };
type VerifyBootstrapTokenResult = { ok: boolean; reason?: string };
export type ConnectAuthDecision = {
authResult: GatewayAuthResult;
@ -72,6 +75,12 @@ function resolveDeviceTokenCandidate(connectAuth: HandshakeConnectAuth | null |
return { token: fallbackToken, source: "shared-token-fallback" };
}
function resolveBootstrapTokenCandidate(
connectAuth: HandshakeConnectAuth | null | undefined,
): string | undefined {
return trimToUndefined(connectAuth?.bootstrapToken);
}
export async function resolveConnectAuthState(params: {
resolvedAuth: ResolvedGatewayAuth;
connectAuth: HandshakeConnectAuth | null | undefined;
@ -84,6 +93,9 @@ export async function resolveConnectAuthState(params: {
}): Promise<ConnectAuthState> {
const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth);
const sharedAuthProvided = Boolean(sharedConnectAuth);
const bootstrapTokenCandidate = params.hasDeviceIdentity
? resolveBootstrapTokenCandidate(params.connectAuth)
: undefined;
const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } =
params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {};
const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate);
@ -148,6 +160,7 @@ export async function resolveConnectAuthState(params: {
authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"),
sharedAuthOk,
sharedAuthProvided,
bootstrapTokenCandidate,
deviceTokenCandidate,
deviceTokenCandidateSource,
};
@ -157,10 +170,18 @@ export async function resolveConnectAuthDecision(params: {
state: ConnectAuthState;
hasDeviceIdentity: boolean;
deviceId?: string;
publicKey?: string;
role: string;
scopes: string[];
rateLimiter?: AuthRateLimiter;
clientIp?: string;
verifyBootstrapToken: (params: {
deviceId: string;
publicKey: string;
token: string;
role: string;
scopes: string[];
}) => Promise<VerifyBootstrapTokenResult>;
verifyDeviceToken: (params: {
deviceId: string;
token: string;
@ -172,6 +193,29 @@ export async function resolveConnectAuthDecision(params: {
let authOk = params.state.authOk;
let authMethod = params.state.authMethod;
const bootstrapTokenCandidate = params.state.bootstrapTokenCandidate;
if (
params.hasDeviceIdentity &&
params.deviceId &&
params.publicKey &&
!authOk &&
bootstrapTokenCandidate
) {
const tokenCheck = await params.verifyBootstrapToken({
deviceId: params.deviceId,
publicKey: params.publicKey,
token: bootstrapTokenCandidate,
role: params.role,
scopes: params.scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "bootstrap-token";
} else {
authResult = { ok: false, reason: tokenCheck.reason ?? "bootstrap_token_invalid" };
}
}
const deviceTokenCandidate = params.state.deviceTokenCandidate;
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
return { authResult, authOk, authMethod };

View File

@ -2,7 +2,7 @@ import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-chan
import type { ResolvedGatewayAuth } from "../../auth.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
export type AuthProvidedKind = "token" | "device-token" | "password" | "none";
export type AuthProvidedKind = "token" | "bootstrap-token" | "device-token" | "password" | "none";
export function formatGatewayAuthFailureMessage(params: {
authMode: ResolvedGatewayAuth["mode"];
@ -38,6 +38,8 @@ export function formatGatewayAuthFailureMessage(params: {
return `unauthorized: gateway password mismatch (${passwordHint})`;
case "password_missing_config":
return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)";
case "bootstrap_token_invalid":
return "unauthorized: bootstrap token invalid or expired (scan a fresh setup code)";
case "tailscale_user_missing":
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
case "tailscale_proxy_missing":
@ -60,6 +62,9 @@ export function formatGatewayAuthFailureMessage(params: {
if (authMode === "token" && authProvided === "device-token") {
return "unauthorized: device token rejected (pair/repair this device, or provide gateway token)";
}
if (authProvided === "bootstrap-token") {
return "unauthorized: bootstrap token invalid or expired (scan a fresh setup code)";
}
if (authMode === "password" && authProvided === "none") {
return `unauthorized: gateway password missing (${passwordHint})`;
}

View File

@ -2,6 +2,7 @@ import type { IncomingMessage } from "node:http";
import os from "node:os";
import type { WebSocket } from "ws";
import { loadConfig } from "../../../config/config.js";
import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js";
import {
deriveDeviceIdFromPublicKey,
normalizeDevicePublicKeyBase64Url,
@ -186,7 +187,11 @@ function resolveDeviceSignaturePayloadVersion(params: {
role: params.role,
scopes: params.scopes,
signedAtMs: params.signedAtMs,
token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null,
token:
params.connectParams.auth?.token ??
params.connectParams.auth?.deviceToken ??
params.connectParams.auth?.bootstrapToken ??
null,
nonce: params.nonce,
platform: params.connectParams.client.platform,
deviceFamily: params.connectParams.client.deviceFamily,
@ -202,7 +207,11 @@ function resolveDeviceSignaturePayloadVersion(params: {
role: params.role,
scopes: params.scopes,
signedAtMs: params.signedAtMs,
token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null,
token:
params.connectParams.auth?.token ??
params.connectParams.auth?.deviceToken ??
params.connectParams.auth?.bootstrapToken ??
null,
nonce: params.nonce,
});
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
@ -566,6 +575,7 @@ export function attachGatewayWsMessageHandler(params: {
authOk,
authMethod,
sharedAuthOk,
bootstrapTokenCandidate,
deviceTokenCandidate,
deviceTokenCandidateSource,
} = await resolveConnectAuthState({
@ -610,9 +620,11 @@ export function attachGatewayWsMessageHandler(params: {
? "password"
: connectParams.auth?.token
? "token"
: connectParams.auth?.deviceToken
? "device-token"
: "none",
: connectParams.auth?.bootstrapToken
? "bootstrap-token"
: connectParams.auth?.deviceToken
? "device-token"
: "none",
authReason: failedAuth.reason,
allowTailscale: resolvedAuth.allowTailscale,
});
@ -623,9 +635,11 @@ export function attachGatewayWsMessageHandler(params: {
? "password"
: connectParams.auth?.token
? "token"
: connectParams.auth?.deviceToken
? "device-token"
: "none";
: connectParams.auth?.bootstrapToken
? "bootstrap-token"
: connectParams.auth?.deviceToken
? "device-token"
: "none";
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,
@ -774,15 +788,25 @@ export function attachGatewayWsMessageHandler(params: {
authMethod,
sharedAuthOk,
sharedAuthProvided: hasSharedAuth,
bootstrapTokenCandidate,
deviceTokenCandidate,
deviceTokenCandidateSource,
},
hasDeviceIdentity: Boolean(device),
deviceId: device?.id,
publicKey: device?.publicKey,
role,
scopes,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp,
verifyBootstrapToken: async ({ deviceId, publicKey, token, role, scopes }) =>
await verifyDeviceBootstrapToken({
deviceId,
publicKey,
token,
role,
scopes,
}),
verifyDeviceToken,
}));
if (!authOk) {

View File

@ -0,0 +1,98 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
issueDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "./device-bootstrap.js";
const tempRoots: string[] = [];
async function createBaseDir(): Promise<string> {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-bootstrap-"));
tempRoots.push(baseDir);
return baseDir;
}
afterEach(async () => {
vi.useRealTimers();
await Promise.all(
tempRoots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true })),
);
});
describe("device bootstrap tokens", () => {
it("binds the first successful verification to a device identity", async () => {
const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "node",
scopes: ["node.invoke"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "operator",
scopes: ["operator.read"],
baseDir,
}),
).resolves.toEqual({ ok: true });
});
it("rejects reuse from a different device after binding", async () => {
const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "node",
scopes: ["node.invoke"],
baseDir,
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-2",
publicKey: "pub-2",
role: "node",
scopes: ["node.invoke"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("expires bootstrap tokens after the ttl window", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-12T10:00:00Z"));
const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
vi.setSystemTime(new Date(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS + 1));
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "node",
scopes: ["node.invoke"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
});

View File

@ -0,0 +1,152 @@
import path from "node:path";
import { resolvePairingPaths } from "./pairing-files.js";
import {
createAsyncLock,
pruneExpiredPending,
readJsonFile,
writeJsonAtomic,
} from "./pairing-files.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
export const DEVICE_BOOTSTRAP_TOKEN_TTL_MS = 10 * 60 * 1000;
export type DeviceBootstrapTokenRecord = {
token: string;
ts: number;
deviceId?: string;
publicKey?: string;
roles?: string[];
scopes?: string[];
channel?: string;
senderId?: string;
accountId?: string;
threadId?: string;
issuedAtMs: number;
lastUsedAtMs?: number;
};
type DeviceBootstrapStateFile = Record<string, DeviceBootstrapTokenRecord>;
const withLock = createAsyncLock();
function normalizeOptionalString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function mergeRoles(existing: string[] | undefined, role: string): string[] {
const out = new Set<string>(existing ?? []);
const trimmed = role.trim();
if (trimmed) {
out.add(trimmed);
}
return [...out];
}
function mergeScopes(
existing: string[] | undefined,
scopes: readonly string[],
): string[] | undefined {
const out = new Set<string>(existing ?? []);
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) {
out.add(trimmed);
}
}
return out.size > 0 ? [...out] : undefined;
}
function resolveBootstrapPath(baseDir?: string): string {
return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json");
}
async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
const bootstrapPath = resolveBootstrapPath(baseDir);
const state = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
for (const entry of Object.values(state)) {
if (typeof entry.ts !== "number") {
entry.ts = entry.issuedAtMs;
}
}
pruneExpiredPending(state, Date.now(), DEVICE_BOOTSTRAP_TOKEN_TTL_MS);
return state;
}
async function persistState(state: DeviceBootstrapStateFile, baseDir?: string): Promise<void> {
const bootstrapPath = resolveBootstrapPath(baseDir);
await writeJsonAtomic(bootstrapPath, state);
}
export async function issueDeviceBootstrapToken(
params: {
channel?: string;
senderId?: string;
accountId?: string;
threadId?: string;
baseDir?: string;
} = {},
): Promise<{ token: string; expiresAtMs: number }> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const token = generatePairingToken();
const issuedAtMs = Date.now();
state[token] = {
token,
ts: issuedAtMs,
channel: normalizeOptionalString(params.channel),
senderId: normalizeOptionalString(params.senderId),
accountId: normalizeOptionalString(params.accountId),
threadId: normalizeOptionalString(params.threadId),
issuedAtMs,
};
await persistState(state, params.baseDir);
return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS };
});
}
export async function verifyDeviceBootstrapToken(params: {
token: string;
deviceId: string;
publicKey: string;
role: string;
scopes: readonly string[];
baseDir?: string;
}): Promise<{ ok: true } | { ok: false; reason: string }> {
return await withLock(async () => {
const state = await loadState(params.baseDir);
const providedToken = params.token.trim();
if (!providedToken) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const entry = Object.values(state).find((candidate) =>
verifyPairingToken(providedToken, candidate.token),
);
if (!entry) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
const deviceId = params.deviceId.trim();
const publicKey = params.publicKey.trim();
const role = params.role.trim();
if (!deviceId || !publicKey || !role) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
if (entry.deviceId && entry.deviceId !== deviceId) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
if (entry.publicKey && entry.publicKey !== publicKey) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
entry.deviceId = deviceId;
entry.publicKey = publicKey;
entry.roles = mergeRoles(entry.roles, role);
entry.scopes = mergeScopes(entry.scopes, params.scopes);
entry.lastUsedAtMs = Date.now();
state[entry.token] = entry;
await persistState(state, params.baseDir);
return { ok: true };
});
}

View File

@ -2,6 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SecretInput } from "../config/types.secrets.js";
import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "./setup-code.js";
vi.mock("../infra/device-bootstrap.js", () => ({
issueDeviceBootstrapToken: vi.fn(async () => ({
token: "bootstrap-123",
expiresAtMs: 123,
})),
}));
describe("pairing setup code", () => {
function createTailnetDnsRunner() {
return vi.fn(async () => ({
@ -25,10 +32,12 @@ describe("pairing setup code", () => {
it("encodes payload as base64url JSON", () => {
const code = encodePairingSetupCode({
url: "wss://gateway.example.com:443",
token: "abc",
bootstrapToken: "abc",
});
expect(code).toBe("eyJ1cmwiOiJ3c3M6Ly9nYXRld2F5LmV4YW1wbGUuY29tOjQ0MyIsInRva2VuIjoiYWJjIn0");
expect(code).toBe(
"eyJ1cmwiOiJ3c3M6Ly9nYXRld2F5LmV4YW1wbGUuY29tOjQ0MyIsImJvb3RzdHJhcFRva2VuIjoiYWJjIn0",
);
});
it("resolves custom bind + token auth", async () => {
@ -45,8 +54,7 @@ describe("pairing setup code", () => {
ok: true,
payload: {
url: "ws://gateway.local:19001",
token: "tok_123",
password: undefined,
bootstrapToken: "bootstrap-123",
},
authLabel: "token",
urlSource: "gateway.bind=custom",
@ -81,7 +89,7 @@ describe("pairing setup code", () => {
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.payload.password).toBe("resolved-password");
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
expect(resolved.authLabel).toBe("password");
});
@ -113,7 +121,7 @@ describe("pairing setup code", () => {
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.payload.password).toBe("password-from-env");
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
expect(resolved.authLabel).toBe("password");
});
@ -145,7 +153,7 @@ describe("pairing setup code", () => {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("token");
expect(resolved.payload.token).toBe("tok_123");
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
});
it("resolves gateway.auth.token SecretRef for pairing payload", async () => {
@ -177,7 +185,7 @@ describe("pairing setup code", () => {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("token");
expect(resolved.payload.token).toBe("resolved-token");
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
});
it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => {
@ -239,7 +247,7 @@ describe("pairing setup code", () => {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("password");
expect(resolved.payload.password).toBe("password-from-env");
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
});
it("does not treat env-template token as plaintext in inferred mode", async () => {
@ -250,8 +258,7 @@ describe("pairing setup code", () => {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("password");
expect(resolved.payload.token).toBeUndefined();
expect(resolved.payload.password).toBe("password-from-env");
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
});
it("requires explicit auth mode when token and password are both configured", async () => {
@ -329,7 +336,7 @@ describe("pairing setup code", () => {
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.payload.token).toBe("new-token");
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
});
it("errors when gateway is loopback only", async () => {
@ -366,8 +373,7 @@ describe("pairing setup code", () => {
ok: true,
payload: {
url: "wss://mb-server.tailnet.ts.net",
token: undefined,
password: "secret",
bootstrapToken: "bootstrap-123",
},
authLabel: "password",
urlSource: "gateway.tailscale.mode=serve",
@ -395,8 +401,7 @@ describe("pairing setup code", () => {
ok: true,
payload: {
url: "wss://remote.example.com:444",
token: "tok_123",
password: undefined,
bootstrapToken: "bootstrap-123",
},
authLabel: "token",
urlSource: "gateway.remote.url",

View File

@ -8,14 +8,14 @@ import {
} from "../config/types.secrets.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js";
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
export type PairingSetupPayload = {
url: string;
token?: string;
password?: string;
bootstrapToken: string;
};
export type PairingSetupCommandResult = {
@ -34,6 +34,7 @@ export type ResolvePairingSetupOptions = {
publicUrl?: string;
preferRemoteUrl?: boolean;
forceSecure?: boolean;
pairingBaseDir?: string;
runCommandWithTimeout?: PairingSetupCommandRunner;
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
};
@ -388,8 +389,11 @@ export async function resolvePairingSetupFromConfig(
ok: true,
payload: {
url: urlResult.url,
token: auth.token,
password: auth.password,
bootstrapToken: (
await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
})
).token,
},
authLabel: auth.label,
urlSource: urlResult.source ?? "unknown",

View File

@ -2,6 +2,7 @@
// Keep this list additive and scoped to symbols used under extensions/device-pair.
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
export type { OpenClawPluginApi } from "../plugins/types.js";
export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";

View File

@ -69,6 +69,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
const code = resolveGatewayErrorDetailCode(error);
return (
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||