mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 03:07:27 +00:00
fix: switch pairing setup codes to bootstrap tokens
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user