fix: switch pairing setup codes to bootstrap tokens
This commit is contained in:
parent
4ca84acf24
commit
0aa1c69a79
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"]))
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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()),
|
||||
},
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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})`;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
98
src/infra/device-bootstrap.test.ts
Normal file
98
src/infra/device-bootstrap.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
152
src/infra/device-bootstrap.ts
Normal file
152
src/infra/device-bootstrap.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 ||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user