Compare commits
1 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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)
|
runtime.setGatewayToken(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setGatewayBootstrapToken(value: String) {
|
||||||
|
runtime.setGatewayBootstrapToken(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun setGatewayPassword(value: String) {
|
fun setGatewayPassword(value: String) {
|
||||||
runtime.setGatewayPassword(value)
|
runtime.setGatewayPassword(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -503,6 +503,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
||||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||||
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
|
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
|
||||||
|
fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value)
|
||||||
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
|
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
|
||||||
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
||||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
@ -698,10 +699,25 @@ class NodeRuntime(context: Context) {
|
|||||||
operatorStatusText = "Connecting…"
|
operatorStatusText = "Connecting…"
|
||||||
updateStatus()
|
updateStatus()
|
||||||
val token = prefs.loadGatewayToken()
|
val token = prefs.loadGatewayToken()
|
||||||
|
val bootstrapToken = prefs.loadGatewayBootstrapToken()
|
||||||
val password = prefs.loadGatewayPassword()
|
val password = prefs.loadGatewayPassword()
|
||||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||||
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
|
operatorSession.connect(
|
||||||
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
|
endpoint,
|
||||||
|
token,
|
||||||
|
bootstrapToken,
|
||||||
|
password,
|
||||||
|
connectionManager.buildOperatorConnectOptions(),
|
||||||
|
tls,
|
||||||
|
)
|
||||||
|
nodeSession.connect(
|
||||||
|
endpoint,
|
||||||
|
token,
|
||||||
|
bootstrapToken,
|
||||||
|
password,
|
||||||
|
connectionManager.buildNodeConnectOptions(),
|
||||||
|
tls,
|
||||||
|
)
|
||||||
operatorSession.reconnect()
|
operatorSession.reconnect()
|
||||||
nodeSession.reconnect()
|
nodeSession.reconnect()
|
||||||
}
|
}
|
||||||
@ -726,9 +742,24 @@ class NodeRuntime(context: Context) {
|
|||||||
nodeStatusText = "Connecting…"
|
nodeStatusText = "Connecting…"
|
||||||
updateStatus()
|
updateStatus()
|
||||||
val token = prefs.loadGatewayToken()
|
val token = prefs.loadGatewayToken()
|
||||||
|
val bootstrapToken = prefs.loadGatewayBootstrapToken()
|
||||||
val password = prefs.loadGatewayPassword()
|
val password = prefs.loadGatewayPassword()
|
||||||
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
|
operatorSession.connect(
|
||||||
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
|
endpoint,
|
||||||
|
token,
|
||||||
|
bootstrapToken,
|
||||||
|
password,
|
||||||
|
connectionManager.buildOperatorConnectOptions(),
|
||||||
|
tls,
|
||||||
|
)
|
||||||
|
nodeSession.connect(
|
||||||
|
endpoint,
|
||||||
|
token,
|
||||||
|
bootstrapToken,
|
||||||
|
password,
|
||||||
|
connectionManager.buildNodeConnectOptions(),
|
||||||
|
tls,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun acceptGatewayTrustPrompt() {
|
fun acceptGatewayTrustPrompt() {
|
||||||
|
|||||||
@ -15,7 +15,10 @@ import kotlinx.serialization.json.JsonNull
|
|||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class SecurePrefs(context: Context) {
|
class SecurePrefs(
|
||||||
|
context: Context,
|
||||||
|
private val securePrefsOverride: SharedPreferences? = null,
|
||||||
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
||||||
private const val displayNameKey = "node.displayName"
|
private const val displayNameKey = "node.displayName"
|
||||||
@ -35,7 +38,7 @@ class SecurePrefs(context: Context) {
|
|||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.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())
|
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||||
val instanceId: StateFlow<String> = _instanceId
|
val instanceId: StateFlow<String> = _instanceId
|
||||||
@ -76,6 +79,9 @@ class SecurePrefs(context: Context) {
|
|||||||
private val _gatewayToken = MutableStateFlow("")
|
private val _gatewayToken = MutableStateFlow("")
|
||||||
val gatewayToken: StateFlow<String> = _gatewayToken
|
val gatewayToken: StateFlow<String> = _gatewayToken
|
||||||
|
|
||||||
|
private val _gatewayBootstrapToken = MutableStateFlow("")
|
||||||
|
val gatewayBootstrapToken: StateFlow<String> = _gatewayBootstrapToken
|
||||||
|
|
||||||
private val _onboardingCompleted =
|
private val _onboardingCompleted =
|
||||||
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
|
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
|
||||||
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
|
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
|
||||||
@ -165,6 +171,10 @@ class SecurePrefs(context: Context) {
|
|||||||
saveGatewayPassword(value)
|
saveGatewayPassword(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setGatewayBootstrapToken(value: String) {
|
||||||
|
saveGatewayBootstrapToken(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun setOnboardingCompleted(value: Boolean) {
|
fun setOnboardingCompleted(value: Boolean) {
|
||||||
plainPrefs.edit { putBoolean("onboarding.completed", value) }
|
plainPrefs.edit { putBoolean("onboarding.completed", value) }
|
||||||
_onboardingCompleted.value = value
|
_onboardingCompleted.value = value
|
||||||
@ -193,6 +203,26 @@ class SecurePrefs(context: Context) {
|
|||||||
securePrefs.edit { putString(key, token.trim()) }
|
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? {
|
fun loadGatewayPassword(): String? {
|
||||||
val key = "gateway.password.${_instanceId.value}"
|
val key = "gateway.password.${_instanceId.value}"
|
||||||
val stored = securePrefs.getString(key, null)?.trim()
|
val stored = securePrefs.getString(key, null)?.trim()
|
||||||
|
|||||||
@ -95,6 +95,7 @@ class GatewaySession(
|
|||||||
private data class DesiredConnection(
|
private data class DesiredConnection(
|
||||||
val endpoint: GatewayEndpoint,
|
val endpoint: GatewayEndpoint,
|
||||||
val token: String?,
|
val token: String?,
|
||||||
|
val bootstrapToken: String?,
|
||||||
val password: String?,
|
val password: String?,
|
||||||
val options: GatewayConnectOptions,
|
val options: GatewayConnectOptions,
|
||||||
val tls: GatewayTlsParams?,
|
val tls: GatewayTlsParams?,
|
||||||
@ -107,11 +108,12 @@ class GatewaySession(
|
|||||||
fun connect(
|
fun connect(
|
||||||
endpoint: GatewayEndpoint,
|
endpoint: GatewayEndpoint,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
options: GatewayConnectOptions,
|
options: GatewayConnectOptions,
|
||||||
tls: GatewayTlsParams? = null,
|
tls: GatewayTlsParams? = null,
|
||||||
) {
|
) {
|
||||||
desired = DesiredConnection(endpoint, token, password, options, tls)
|
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
}
|
}
|
||||||
@ -219,6 +221,7 @@ class GatewaySession(
|
|||||||
private inner class Connection(
|
private inner class Connection(
|
||||||
private val endpoint: GatewayEndpoint,
|
private val endpoint: GatewayEndpoint,
|
||||||
private val token: String?,
|
private val token: String?,
|
||||||
|
private val bootstrapToken: String?,
|
||||||
private val password: String?,
|
private val password: String?,
|
||||||
private val options: GatewayConnectOptions,
|
private val options: GatewayConnectOptions,
|
||||||
private val tls: GatewayTlsParams?,
|
private val tls: GatewayTlsParams?,
|
||||||
@ -346,9 +349,18 @@ class GatewaySession(
|
|||||||
val identity = identityStore.loadOrCreate()
|
val identity = identityStore.loadOrCreate()
|
||||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
||||||
val trimmedToken = token?.trim().orEmpty()
|
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.
|
// 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 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)
|
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
val msg = res.error?.message ?: "connect failed"
|
val msg = res.error?.message ?: "connect failed"
|
||||||
@ -381,6 +393,7 @@ class GatewaySession(
|
|||||||
identity: DeviceIdentity,
|
identity: DeviceIdentity,
|
||||||
connectNonce: String,
|
connectNonce: String,
|
||||||
authToken: String,
|
authToken: String,
|
||||||
|
authBootstrapToken: String,
|
||||||
authPassword: String?,
|
authPassword: String?,
|
||||||
): JsonObject {
|
): JsonObject {
|
||||||
val client = options.client
|
val client = options.client
|
||||||
@ -404,6 +417,10 @@ class GatewaySession(
|
|||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("token", JsonPrimitive(authToken))
|
put("token", JsonPrimitive(authToken))
|
||||||
}
|
}
|
||||||
|
authBootstrapToken.isNotEmpty() ->
|
||||||
|
buildJsonObject {
|
||||||
|
put("bootstrapToken", JsonPrimitive(authBootstrapToken))
|
||||||
|
}
|
||||||
password.isNotEmpty() ->
|
password.isNotEmpty() ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("password", JsonPrimitive(password))
|
put("password", JsonPrimitive(password))
|
||||||
@ -420,7 +437,12 @@ class GatewaySession(
|
|||||||
role = options.role,
|
role = options.role,
|
||||||
scopes = options.scopes,
|
scopes = options.scopes,
|
||||||
signedAtMs = signedAtMs,
|
signedAtMs = signedAtMs,
|
||||||
token = if (authToken.isNotEmpty()) authToken else null,
|
token =
|
||||||
|
when {
|
||||||
|
authToken.isNotEmpty() -> authToken
|
||||||
|
authBootstrapToken.isNotEmpty() -> authBootstrapToken
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
nonce = connectNonce,
|
nonce = connectNonce,
|
||||||
platform = client.platform,
|
platform = client.platform,
|
||||||
deviceFamily = client.deviceFamily,
|
deviceFamily = client.deviceFamily,
|
||||||
@ -622,7 +644,15 @@ class GatewaySession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
|
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
|
currentConnection = conn
|
||||||
try {
|
try {
|
||||||
conn.connect()
|
conn.connect()
|
||||||
|
|||||||
@ -200,8 +200,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
|||||||
viewModel.setManualHost(config.host)
|
viewModel.setManualHost(config.host)
|
||||||
viewModel.setManualPort(config.port)
|
viewModel.setManualPort(config.port)
|
||||||
viewModel.setManualTls(config.tls)
|
viewModel.setManualTls(config.tls)
|
||||||
|
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
|
||||||
if (config.token.isNotBlank()) {
|
if (config.token.isNotBlank()) {
|
||||||
viewModel.setGatewayToken(config.token)
|
viewModel.setGatewayToken(config.token)
|
||||||
|
} else if (config.bootstrapToken.isNotBlank()) {
|
||||||
|
viewModel.setGatewayToken("")
|
||||||
}
|
}
|
||||||
viewModel.setGatewayPassword(config.password)
|
viewModel.setGatewayPassword(config.password)
|
||||||
viewModel.connectManual()
|
viewModel.connectManual()
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package ai.openclaw.app.ui
|
package ai.openclaw.app.ui
|
||||||
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.net.URI
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
@ -18,6 +18,7 @@ internal data class GatewayEndpointConfig(
|
|||||||
|
|
||||||
internal data class GatewaySetupCode(
|
internal data class GatewaySetupCode(
|
||||||
val url: String,
|
val url: String,
|
||||||
|
val bootstrapToken: String?,
|
||||||
val token: String?,
|
val token: String?,
|
||||||
val password: String?,
|
val password: String?,
|
||||||
)
|
)
|
||||||
@ -26,6 +27,7 @@ internal data class GatewayConnectConfig(
|
|||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
val tls: Boolean,
|
val tls: Boolean,
|
||||||
|
val bootstrapToken: String,
|
||||||
val token: String,
|
val token: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
)
|
)
|
||||||
@ -44,12 +46,26 @@ internal fun resolveGatewayConnectConfig(
|
|||||||
if (useSetupCode) {
|
if (useSetupCode) {
|
||||||
val setup = decodeGatewaySetupCode(setupCode) ?: return null
|
val setup = decodeGatewaySetupCode(setupCode) ?: return null
|
||||||
val parsed = parseGatewayEndpoint(setup.url) ?: 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(
|
return GatewayConnectConfig(
|
||||||
host = parsed.host,
|
host = parsed.host,
|
||||||
port = parsed.port,
|
port = parsed.port,
|
||||||
tls = parsed.tls,
|
tls = parsed.tls,
|
||||||
token = setup.token ?: fallbackToken.trim(),
|
bootstrapToken = setupBootstrapToken,
|
||||||
password = setup.password ?: fallbackPassword.trim(),
|
token = sharedToken,
|
||||||
|
password = sharedPassword,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,6 +75,7 @@ internal fun resolveGatewayConnectConfig(
|
|||||||
host = parsed.host,
|
host = parsed.host,
|
||||||
port = parsed.port,
|
port = parsed.port,
|
||||||
tls = parsed.tls,
|
tls = parsed.tls,
|
||||||
|
bootstrapToken = "",
|
||||||
token = fallbackToken.trim(),
|
token = fallbackToken.trim(),
|
||||||
password = fallbackPassword.trim(),
|
password = fallbackPassword.trim(),
|
||||||
)
|
)
|
||||||
@ -69,7 +86,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
|||||||
if (raw.isEmpty()) return null
|
if (raw.isEmpty()) return null
|
||||||
|
|
||||||
val normalized = if (raw.contains("://")) raw else "https://$raw"
|
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()
|
val host = uri.host?.trim().orEmpty()
|
||||||
if (host.isEmpty()) return null
|
if (host.isEmpty()) return null
|
||||||
|
|
||||||
@ -104,9 +121,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
|||||||
val obj = parseJsonObject(decoded) ?: return null
|
val obj = parseJsonObject(decoded) ?: return null
|
||||||
val url = jsonField(obj, "url").orEmpty()
|
val url = jsonField(obj, "url").orEmpty()
|
||||||
if (url.isEmpty()) return null
|
if (url.isEmpty()) return null
|
||||||
|
val bootstrapToken = jsonField(obj, "bootstrapToken")
|
||||||
val token = jsonField(obj, "token")
|
val token = jsonField(obj, "token")
|
||||||
val password = jsonField(obj, "password")
|
val password = jsonField(obj, "password")
|
||||||
GatewaySetupCode(url = url, token = token, password = password)
|
GatewaySetupCode(url = url, bootstrapToken = bootstrapToken, token = token, password = password)
|
||||||
} catch (_: IllegalArgumentException) {
|
} catch (_: IllegalArgumentException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -772,8 +772,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
gatewayUrl = parsedSetup.url
|
gatewayUrl = parsedSetup.url
|
||||||
parsedSetup.token?.let { viewModel.setGatewayToken(it) }
|
viewModel.setGatewayBootstrapToken(parsedSetup.bootstrapToken.orEmpty())
|
||||||
gatewayPassword = parsedSetup.password.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 {
|
} else {
|
||||||
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
|
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
|
||||||
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
|
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
|
||||||
@ -782,6 +792,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
gatewayUrl = parsedGateway.displayUrl
|
gatewayUrl = parsedGateway.displayUrl
|
||||||
|
viewModel.setGatewayBootstrapToken("")
|
||||||
}
|
}
|
||||||
step = OnboardingStep.Permissions
|
step = OnboardingStep.Permissions
|
||||||
},
|
},
|
||||||
@ -850,8 +861,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|||||||
viewModel.setManualHost(parsed.host)
|
viewModel.setManualHost(parsed.host)
|
||||||
viewModel.setManualPort(parsed.port)
|
viewModel.setManualPort(parsed.port)
|
||||||
viewModel.setManualTls(parsed.tls)
|
viewModel.setManualTls(parsed.tls)
|
||||||
|
if (gatewayInputMode == GatewayInputMode.Manual) {
|
||||||
|
viewModel.setGatewayBootstrapToken("")
|
||||||
|
}
|
||||||
if (token.isNotEmpty()) {
|
if (token.isNotEmpty()) {
|
||||||
viewModel.setGatewayToken(token)
|
viewModel.setGatewayToken(token)
|
||||||
|
} else {
|
||||||
|
viewModel.setGatewayToken("")
|
||||||
}
|
}
|
||||||
viewModel.setGatewayPassword(password)
|
viewModel.setGatewayPassword(password)
|
||||||
viewModel.connectManual()
|
viewModel.connectManual()
|
||||||
|
|||||||
@ -20,4 +20,19 @@ class SecurePrefsTest {
|
|||||||
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
|
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
|
||||||
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
|
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(
|
private data class NodeHarness(
|
||||||
val session: GatewaySession,
|
val session: GatewaySession,
|
||||||
val sessionJob: Job,
|
val sessionJob: Job,
|
||||||
|
val deviceAuthStore: InMemoryDeviceAuthStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class InvokeScenarioResult(
|
private data class InvokeScenarioResult(
|
||||||
@ -56,6 +57,93 @@ private data class InvokeScenarioResult(
|
|||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34])
|
@Config(sdk = [34])
|
||||||
class GatewaySessionInvokeTest {
|
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
|
@Test
|
||||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||||
val handshakeOrigin = AtomicReference<String?>(null)
|
val handshakeOrigin = AtomicReference<String?>(null)
|
||||||
@ -182,11 +270,12 @@ class GatewaySessionInvokeTest {
|
|||||||
): NodeHarness {
|
): NodeHarness {
|
||||||
val app = RuntimeEnvironment.getApplication()
|
val app = RuntimeEnvironment.getApplication()
|
||||||
val sessionJob = SupervisorJob()
|
val sessionJob = SupervisorJob()
|
||||||
|
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||||
val session =
|
val session =
|
||||||
GatewaySession(
|
GatewaySession(
|
||||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||||
identityStore = DeviceIdentityStore(app),
|
identityStore = DeviceIdentityStore(app),
|
||||||
deviceAuthStore = InMemoryDeviceAuthStore(),
|
deviceAuthStore = deviceAuthStore,
|
||||||
onConnected = { _, _, _ ->
|
onConnected = { _, _, _ ->
|
||||||
if (!connected.isCompleted) connected.complete(Unit)
|
if (!connected.isCompleted) connected.complete(Unit)
|
||||||
},
|
},
|
||||||
@ -197,10 +286,15 @@ class GatewaySessionInvokeTest {
|
|||||||
onInvoke = onInvoke,
|
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(
|
session.connect(
|
||||||
endpoint =
|
endpoint =
|
||||||
GatewayEndpoint(
|
GatewayEndpoint(
|
||||||
@ -210,7 +304,8 @@ class GatewaySessionInvokeTest {
|
|||||||
port = port,
|
port = port,
|
||||||
tlsEnabled = false,
|
tlsEnabled = false,
|
||||||
),
|
),
|
||||||
token = "test-token",
|
token = token,
|
||||||
|
bootstrapToken = bootstrapToken,
|
||||||
password = null,
|
password = null,
|
||||||
options =
|
options =
|
||||||
GatewayConnectOptions(
|
GatewayConnectOptions(
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import org.junit.Test
|
|||||||
class GatewayConfigResolverTest {
|
class GatewayConfigResolverTest {
|
||||||
@Test
|
@Test
|
||||||
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
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)
|
val resolved = resolveScannedSetupCode(setupCode)
|
||||||
|
|
||||||
@ -17,7 +18,8 @@ class GatewayConfigResolverTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
|
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 =
|
val qrJson =
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@ -53,6 +55,43 @@ class GatewayConfigResolverTest {
|
|||||||
assertNull(resolved)
|
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 {
|
private fun encodeSetupCode(payloadJson: String): String {
|
||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct GatewayConnectConfig: Sendable {
|
|||||||
let stableID: String
|
let stableID: String
|
||||||
let tls: GatewayTLSParams?
|
let tls: GatewayTLSParams?
|
||||||
let token: String?
|
let token: String?
|
||||||
|
let bootstrapToken: String?
|
||||||
let password: String?
|
let password: String?
|
||||||
let nodeOptions: GatewayConnectOptions
|
let nodeOptions: GatewayConnectOptions
|
||||||
|
|
||||||
|
|||||||
@ -101,6 +101,7 @@ final class GatewayConnectionController {
|
|||||||
return "Missing instanceId (node.instanceId). Try restarting the app."
|
return "Missing instanceId (node.instanceId). Try restarting the app."
|
||||||
}
|
}
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
|
|
||||||
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
||||||
@ -151,6 +152,7 @@ final class GatewayConnectionController {
|
|||||||
gatewayStableID: stableID,
|
gatewayStableID: stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -163,6 +165,7 @@ final class GatewayConnectionController {
|
|||||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
||||||
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
||||||
@ -203,6 +206,7 @@ final class GatewayConnectionController {
|
|||||||
gatewayStableID: stableID,
|
gatewayStableID: stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,6 +233,7 @@ final class GatewayConnectionController {
|
|||||||
stableID: cfg.stableID,
|
stableID: cfg.stableID,
|
||||||
tls: cfg.tls,
|
tls: cfg.tls,
|
||||||
token: cfg.token,
|
token: cfg.token,
|
||||||
|
bootstrapToken: cfg.bootstrapToken,
|
||||||
password: cfg.password,
|
password: cfg.password,
|
||||||
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
|
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
|
||||||
appModel.applyGatewayConnectConfig(refreshedConfig)
|
appModel.applyGatewayConnectConfig(refreshedConfig)
|
||||||
@ -261,6 +266,7 @@ final class GatewayConnectionController {
|
|||||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
let tlsParams = GatewayTLSParams(
|
let tlsParams = GatewayTLSParams(
|
||||||
required: true,
|
required: true,
|
||||||
@ -274,6 +280,7 @@ final class GatewayConnectionController {
|
|||||||
gatewayStableID: pending.stableID,
|
gatewayStableID: pending.stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,6 +326,7 @@ final class GatewayConnectionController {
|
|||||||
guard !instanceId.isEmpty else { return }
|
guard !instanceId.isEmpty else { return }
|
||||||
|
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
|
|
||||||
if manualEnabled {
|
if manualEnabled {
|
||||||
@ -353,6 +361,7 @@ final class GatewayConnectionController {
|
|||||||
gatewayStableID: stableID,
|
gatewayStableID: stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -379,6 +388,7 @@ final class GatewayConnectionController {
|
|||||||
gatewayStableID: stableID,
|
gatewayStableID: stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -448,6 +458,7 @@ final class GatewayConnectionController {
|
|||||||
gatewayStableID: String,
|
gatewayStableID: String,
|
||||||
tls: GatewayTLSParams?,
|
tls: GatewayTLSParams?,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?)
|
password: String?)
|
||||||
{
|
{
|
||||||
guard let appModel else { return }
|
guard let appModel else { return }
|
||||||
@ -463,6 +474,7 @@ final class GatewayConnectionController {
|
|||||||
stableID: gatewayStableID,
|
stableID: gatewayStableID,
|
||||||
tls: tls,
|
tls: tls,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
nodeOptions: connectOptions)
|
nodeOptions: connectOptions)
|
||||||
appModel.applyGatewayConnectConfig(cfg)
|
appModel.applyGatewayConnectConfig(cfg)
|
||||||
|
|||||||
@ -104,6 +104,21 @@ enum GatewaySettingsStore {
|
|||||||
account: self.gatewayTokenAccount(instanceId: instanceId))
|
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? {
|
static func loadGatewayPassword(instanceId: String) -> String? {
|
||||||
KeychainStore.loadString(
|
KeychainStore.loadString(
|
||||||
service: self.gatewayService,
|
service: self.gatewayService,
|
||||||
@ -278,6 +293,9 @@ enum GatewaySettingsStore {
|
|||||||
_ = KeychainStore.delete(
|
_ = KeychainStore.delete(
|
||||||
service: self.gatewayService,
|
service: self.gatewayService,
|
||||||
account: self.gatewayTokenAccount(instanceId: trimmed))
|
account: self.gatewayTokenAccount(instanceId: trimmed))
|
||||||
|
_ = KeychainStore.delete(
|
||||||
|
service: self.gatewayService,
|
||||||
|
account: self.gatewayBootstrapTokenAccount(instanceId: trimmed))
|
||||||
_ = KeychainStore.delete(
|
_ = KeychainStore.delete(
|
||||||
service: self.gatewayService,
|
service: self.gatewayService,
|
||||||
account: self.gatewayPasswordAccount(instanceId: trimmed))
|
account: self.gatewayPasswordAccount(instanceId: trimmed))
|
||||||
@ -331,6 +349,10 @@ enum GatewaySettingsStore {
|
|||||||
"gateway-token.\(instanceId)"
|
"gateway-token.\(instanceId)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func gatewayBootstrapTokenAccount(instanceId: String) -> String {
|
||||||
|
"gateway-bootstrap-token.\(instanceId)"
|
||||||
|
}
|
||||||
|
|
||||||
private static func gatewayPasswordAccount(instanceId: String) -> String {
|
private static func gatewayPasswordAccount(instanceId: String) -> String {
|
||||||
"gateway-password.\(instanceId)"
|
"gateway-password.\(instanceId)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ struct GatewaySetupPayload: Codable {
|
|||||||
var host: String?
|
var host: String?
|
||||||
var port: Int?
|
var port: Int?
|
||||||
var tls: Bool?
|
var tls: Bool?
|
||||||
|
var bootstrapToken: String?
|
||||||
var token: String?
|
var token: String?
|
||||||
var password: String?
|
var password: String?
|
||||||
}
|
}
|
||||||
@ -39,4 +40,3 @@ enum GatewaySetupCode {
|
|||||||
return String(data: data, encoding: .utf8)
|
return String(data: data, encoding: .utf8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1680,6 +1680,7 @@ extension NodeAppModel {
|
|||||||
gatewayStableID: String,
|
gatewayStableID: String,
|
||||||
tls: GatewayTLSParams?,
|
tls: GatewayTLSParams?,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
connectOptions: GatewayConnectOptions)
|
connectOptions: GatewayConnectOptions)
|
||||||
{
|
{
|
||||||
@ -1692,6 +1693,7 @@ extension NodeAppModel {
|
|||||||
stableID: stableID,
|
stableID: stableID,
|
||||||
tls: tls,
|
tls: tls,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
nodeOptions: connectOptions)
|
nodeOptions: connectOptions)
|
||||||
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
||||||
@ -1699,6 +1701,7 @@ extension NodeAppModel {
|
|||||||
url: url,
|
url: url,
|
||||||
stableID: effectiveStableID,
|
stableID: effectiveStableID,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
nodeOptions: connectOptions,
|
nodeOptions: connectOptions,
|
||||||
sessionBox: sessionBox)
|
sessionBox: sessionBox)
|
||||||
@ -1706,6 +1709,7 @@ extension NodeAppModel {
|
|||||||
url: url,
|
url: url,
|
||||||
stableID: effectiveStableID,
|
stableID: effectiveStableID,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
nodeOptions: connectOptions,
|
nodeOptions: connectOptions,
|
||||||
sessionBox: sessionBox)
|
sessionBox: sessionBox)
|
||||||
@ -1721,6 +1725,7 @@ extension NodeAppModel {
|
|||||||
gatewayStableID: cfg.stableID,
|
gatewayStableID: cfg.stableID,
|
||||||
tls: cfg.tls,
|
tls: cfg.tls,
|
||||||
token: cfg.token,
|
token: cfg.token,
|
||||||
|
bootstrapToken: cfg.bootstrapToken,
|
||||||
password: cfg.password,
|
password: cfg.password,
|
||||||
connectOptions: cfg.nodeOptions)
|
connectOptions: cfg.nodeOptions)
|
||||||
}
|
}
|
||||||
@ -1801,6 +1806,7 @@ private extension NodeAppModel {
|
|||||||
url: URL,
|
url: URL,
|
||||||
stableID: String,
|
stableID: String,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
nodeOptions: GatewayConnectOptions,
|
nodeOptions: GatewayConnectOptions,
|
||||||
sessionBox: WebSocketSessionBox?)
|
sessionBox: WebSocketSessionBox?)
|
||||||
@ -1838,6 +1844,7 @@ private extension NodeAppModel {
|
|||||||
try await self.operatorGateway.connect(
|
try await self.operatorGateway.connect(
|
||||||
url: url,
|
url: url,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
connectOptions: operatorOptions,
|
connectOptions: operatorOptions,
|
||||||
sessionBox: sessionBox,
|
sessionBox: sessionBox,
|
||||||
@ -1896,6 +1903,7 @@ private extension NodeAppModel {
|
|||||||
url: URL,
|
url: URL,
|
||||||
stableID: String,
|
stableID: String,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
nodeOptions: GatewayConnectOptions,
|
nodeOptions: GatewayConnectOptions,
|
||||||
sessionBox: WebSocketSessionBox?)
|
sessionBox: WebSocketSessionBox?)
|
||||||
@ -1944,6 +1952,7 @@ private extension NodeAppModel {
|
|||||||
try await self.nodeGateway.connect(
|
try await self.nodeGateway.connect(
|
||||||
url: url,
|
url: url,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
connectOptions: currentOptions,
|
connectOptions: currentOptions,
|
||||||
sessionBox: sessionBox,
|
sessionBox: sessionBox,
|
||||||
|
|||||||
@ -275,9 +275,21 @@ private struct ManualEntryStep: View {
|
|||||||
|
|
||||||
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
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 {
|
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
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."
|
self.setupStatusText = "Setup code applied."
|
||||||
|
|||||||
@ -642,11 +642,17 @@ struct OnboardingWizardView: View {
|
|||||||
self.manualHost = link.host
|
self.manualHost = link.host
|
||||||
self.manualPort = link.port
|
self.manualPort = link.port
|
||||||
self.manualTLS = link.tls
|
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
|
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
|
self.gatewayPassword = password
|
||||||
|
} else if trimmedBootstrapToken?.isEmpty == false {
|
||||||
|
self.gatewayPassword = ""
|
||||||
}
|
}
|
||||||
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
|
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
|
||||||
self.showQRScanner = false
|
self.showQRScanner = false
|
||||||
@ -794,6 +800,13 @@ struct OnboardingWizardView: View {
|
|||||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
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 {
|
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||||
self.connectingGatewayID = gateway.id
|
self.connectingGatewayID = gateway.id
|
||||||
self.issue = .none
|
self.issue = .none
|
||||||
|
|||||||
@ -767,12 +767,22 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
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 {
|
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
self.gatewayToken = trimmedToken
|
self.gatewayToken = trimmedToken
|
||||||
if !trimmedInstanceId.isEmpty {
|
if !trimmedInstanceId.isEmpty {
|
||||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
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 {
|
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@ -780,6 +790,11 @@ struct SettingsTab: View {
|
|||||||
if !trimmedInstanceId.isEmpty {
|
if !trimmedInstanceId.isEmpty {
|
||||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
||||||
}
|
}
|
||||||
|
} else if !trimmedBootstrapToken.isEmpty {
|
||||||
|
self.gatewayPassword = ""
|
||||||
|
if !trimmedInstanceId.isEmpty {
|
||||||
|
GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@ -86,7 +86,13 @@ private func agentAction(
|
|||||||
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
|
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
|
||||||
#expect(
|
#expect(
|
||||||
DeepLinkParser.parse(url) == .gateway(
|
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() {
|
@Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() {
|
||||||
@ -102,14 +108,15 @@ private func agentAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
@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))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
|
|
||||||
#expect(link == .init(
|
#expect(link == .init(
|
||||||
host: "gateway.example.com",
|
host: "gateway.example.com",
|
||||||
port: 443,
|
port: 443,
|
||||||
tls: true,
|
tls: true,
|
||||||
token: "tok",
|
bootstrapToken: "tok",
|
||||||
|
token: nil,
|
||||||
password: "pw"))
|
password: "pw"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,38 +125,40 @@ private func agentAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
|
@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))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
|
|
||||||
#expect(link == .init(
|
#expect(link == .init(
|
||||||
host: "gateway.example.com",
|
host: "gateway.example.com",
|
||||||
port: 443,
|
port: 443,
|
||||||
tls: true,
|
tls: true,
|
||||||
token: "tok",
|
bootstrapToken: "tok",
|
||||||
|
token: nil,
|
||||||
password: nil))
|
password: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
|
@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))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
#expect(link == nil)
|
#expect(link == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
|
@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))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
#expect(link == nil)
|
#expect(link == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
|
@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))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
|
|
||||||
#expect(link == .init(
|
#expect(link == .init(
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 18789,
|
port: 18789,
|
||||||
tls: false,
|
tls: false,
|
||||||
token: "tok",
|
bootstrapToken: "tok",
|
||||||
|
token: nil,
|
||||||
password: nil))
|
password: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -324,6 +324,8 @@ final class ControlChannel {
|
|||||||
switch source {
|
switch source {
|
||||||
case .deviceToken:
|
case .deviceToken:
|
||||||
return "Auth: device token (paired device)"
|
return "Auth: device token (paired device)"
|
||||||
|
case .bootstrapToken:
|
||||||
|
return "Auth: bootstrap token (setup code)"
|
||||||
case .sharedToken:
|
case .sharedToken:
|
||||||
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
||||||
case .password:
|
case .password:
|
||||||
|
|||||||
@ -77,6 +77,7 @@ final class MacNodeModeCoordinator {
|
|||||||
try await self.session.connect(
|
try await self.session.connect(
|
||||||
url: config.url,
|
url: config.url,
|
||||||
token: config.token,
|
token: config.token,
|
||||||
|
bootstrapToken: nil,
|
||||||
password: config.password,
|
password: config.password,
|
||||||
connectOptions: connectOptions,
|
connectOptions: connectOptions,
|
||||||
sessionBox: sessionBox,
|
sessionBox: sessionBox,
|
||||||
|
|||||||
@ -508,6 +508,8 @@ extension OnboardingView {
|
|||||||
return ("exclamationmark.triangle.fill", .orange)
|
return ("exclamationmark.triangle.fill", .orange)
|
||||||
case .gatewayTokenNotConfigured:
|
case .gatewayTokenNotConfigured:
|
||||||
return ("wrench.and.screwdriver.fill", .orange)
|
return ("wrench.and.screwdriver.fill", .orange)
|
||||||
|
case .setupCodeExpired:
|
||||||
|
return ("qrcode.viewfinder", .orange)
|
||||||
case .passwordRequired:
|
case .passwordRequired:
|
||||||
return ("lock.slash.fill", .orange)
|
return ("lock.slash.fill", .orange)
|
||||||
case .pairingRequired:
|
case .pairingRequired:
|
||||||
|
|||||||
@ -6,6 +6,7 @@ enum RemoteGatewayAuthIssue: Equatable {
|
|||||||
case tokenRequired
|
case tokenRequired
|
||||||
case tokenMismatch
|
case tokenMismatch
|
||||||
case gatewayTokenNotConfigured
|
case gatewayTokenNotConfigured
|
||||||
|
case setupCodeExpired
|
||||||
case passwordRequired
|
case passwordRequired
|
||||||
case pairingRequired
|
case pairingRequired
|
||||||
|
|
||||||
@ -20,6 +21,8 @@ enum RemoteGatewayAuthIssue: Equatable {
|
|||||||
self = .tokenMismatch
|
self = .tokenMismatch
|
||||||
case .authTokenNotConfigured:
|
case .authTokenNotConfigured:
|
||||||
self = .gatewayTokenNotConfigured
|
self = .gatewayTokenNotConfigured
|
||||||
|
case .authBootstrapTokenInvalid:
|
||||||
|
self = .setupCodeExpired
|
||||||
case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
|
case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
|
||||||
self = .passwordRequired
|
self = .passwordRequired
|
||||||
case .pairingRequired:
|
case .pairingRequired:
|
||||||
@ -33,7 +36,7 @@ enum RemoteGatewayAuthIssue: Equatable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .tokenRequired, .tokenMismatch:
|
case .tokenRequired, .tokenMismatch:
|
||||||
true
|
true
|
||||||
case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired:
|
case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired:
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,6 +49,8 @@ enum RemoteGatewayAuthIssue: Equatable {
|
|||||||
"That token did not match the gateway"
|
"That token did not match the gateway"
|
||||||
case .gatewayTokenNotConfigured:
|
case .gatewayTokenNotConfigured:
|
||||||
"This gateway host needs token setup"
|
"This gateway host needs token setup"
|
||||||
|
case .setupCodeExpired:
|
||||||
|
"This setup code is no longer valid"
|
||||||
case .passwordRequired:
|
case .passwordRequired:
|
||||||
"This gateway is using unsupported auth"
|
"This gateway is using unsupported auth"
|
||||||
case .pairingRequired:
|
case .pairingRequired:
|
||||||
@ -61,6 +66,8 @@ enum RemoteGatewayAuthIssue: Equatable {
|
|||||||
"Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
|
"Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
|
||||||
case .gatewayTokenNotConfigured:
|
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."
|
"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:
|
case .passwordRequired:
|
||||||
"This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
|
"This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
|
||||||
case .pairingRequired:
|
case .pairingRequired:
|
||||||
@ -72,6 +79,8 @@ enum RemoteGatewayAuthIssue: Equatable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .tokenRequired, .gatewayTokenNotConfigured:
|
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`."
|
"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:
|
case .pairingRequired:
|
||||||
"If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`."
|
"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:
|
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."
|
"Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host."
|
||||||
case .gatewayTokenNotConfigured:
|
case .gatewayTokenNotConfigured:
|
||||||
"This gateway has token auth enabled, but no gateway.auth.token is configured on the host."
|
"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:
|
case .passwordRequired:
|
||||||
"This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
|
"This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
|
||||||
case .pairingRequired:
|
case .pairingRequired:
|
||||||
@ -108,6 +119,8 @@ struct RemoteGatewayProbeSuccess: Equatable {
|
|||||||
switch self.authSource {
|
switch self.authSource {
|
||||||
case .some(.deviceToken):
|
case .some(.deviceToken):
|
||||||
"Connected via paired device"
|
"Connected via paired device"
|
||||||
|
case .some(.bootstrapToken):
|
||||||
|
"Connected with setup code"
|
||||||
case .some(.sharedToken):
|
case .some(.sharedToken):
|
||||||
"Connected with gateway token"
|
"Connected with gateway token"
|
||||||
case .some(.password):
|
case .some(.password):
|
||||||
@ -121,6 +134,8 @@ struct RemoteGatewayProbeSuccess: Equatable {
|
|||||||
switch self.authSource {
|
switch self.authSource {
|
||||||
case .some(.deviceToken):
|
case .some(.deviceToken):
|
||||||
"This Mac used a stored device token. New or unpaired devices may still need the gateway token."
|
"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:
|
case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil:
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,10 @@ struct OnboardingRemoteAuthPromptTests {
|
|||||||
message: "token not configured",
|
message: "token not configured",
|
||||||
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
|
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
|
||||||
canRetryWithDeviceToken: false)
|
canRetryWithDeviceToken: false)
|
||||||
|
let bootstrapInvalid = GatewayConnectAuthError(
|
||||||
|
message: "setup code expired",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
let passwordMissing = GatewayConnectAuthError(
|
let passwordMissing = GatewayConnectAuthError(
|
||||||
message: "password missing",
|
message: "password missing",
|
||||||
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
|
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
|
||||||
@ -33,6 +37,7 @@ struct OnboardingRemoteAuthPromptTests {
|
|||||||
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
|
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
|
||||||
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
|
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
|
||||||
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
|
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: bootstrapInvalid) == .setupCodeExpired)
|
||||||
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
|
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
|
||||||
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
|
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
|
||||||
#expect(RemoteGatewayAuthIssue(error: unknown) == nil)
|
#expect(RemoteGatewayAuthIssue(error: unknown) == nil)
|
||||||
@ -88,6 +93,11 @@ struct OnboardingRemoteAuthPromptTests {
|
|||||||
remoteToken: "",
|
remoteToken: "",
|
||||||
remoteTokenUnsupported: false,
|
remoteTokenUnsupported: false,
|
||||||
authIssue: .gatewayTokenNotConfigured) == false)
|
authIssue: .gatewayTokenNotConfigured) == false)
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: .setupCodeExpired) == false)
|
||||||
#expect(OnboardingView.shouldShowRemoteTokenField(
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
showAdvancedConnection: false,
|
showAdvancedConnection: false,
|
||||||
remoteToken: "",
|
remoteToken: "",
|
||||||
@ -106,11 +116,14 @@ struct OnboardingRemoteAuthPromptTests {
|
|||||||
|
|
||||||
@Test func `paired device success copy explains auth source`() {
|
@Test func `paired device success copy explains auth source`() {
|
||||||
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
|
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
|
||||||
|
let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken)
|
||||||
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
|
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
|
||||||
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
|
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
|
||||||
|
|
||||||
#expect(pairedDevice.title == "Connected via paired device")
|
#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(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.title == "Connected with gateway token")
|
||||||
#expect(sharedToken.detail == nil)
|
#expect(sharedToken.detail == nil)
|
||||||
#expect(noAuth.title == "Remote gateway ready")
|
#expect(noAuth.title == "Remote gateway ready")
|
||||||
|
|||||||
@ -9,13 +9,15 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
|||||||
public let host: String
|
public let host: String
|
||||||
public let port: Int
|
public let port: Int
|
||||||
public let tls: Bool
|
public let tls: Bool
|
||||||
|
public let bootstrapToken: String?
|
||||||
public let token: String?
|
public let token: String?
|
||||||
public let password: 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.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.tls = tls
|
self.tls = tls
|
||||||
|
self.bootstrapToken = bootstrapToken
|
||||||
self.token = token
|
self.token = token
|
||||||
self.password = password
|
self.password = password
|
||||||
}
|
}
|
||||||
@ -25,7 +27,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
|||||||
return URL(string: "\(scheme)://\(self.host):\(self.port)")
|
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? {
|
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
|
||||||
guard let data = Self.decodeBase64Url(code) else { return nil }
|
guard let data = Self.decodeBase64Url(code) else { return nil }
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] 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
|
return nil
|
||||||
}
|
}
|
||||||
let port = parsed.port ?? (tls ? 443 : 18789)
|
let port = parsed.port ?? (tls ? 443 : 18789)
|
||||||
|
let bootstrapToken = json["bootstrapToken"] as? String
|
||||||
let token = json["token"] as? String
|
let token = json["token"] as? String
|
||||||
let password = json["password"] 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? {
|
private static func decodeBase64Url(_ input: String) -> Data? {
|
||||||
@ -140,6 +149,7 @@ public enum DeepLinkParser {
|
|||||||
host: hostParam,
|
host: hostParam,
|
||||||
port: port,
|
port: port,
|
||||||
tls: tls,
|
tls: tls,
|
||||||
|
bootstrapToken: nil,
|
||||||
token: query["token"],
|
token: query["token"],
|
||||||
password: query["password"]))
|
password: query["password"]))
|
||||||
|
|
||||||
|
|||||||
@ -112,6 +112,7 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
public enum GatewayAuthSource: String, Sendable {
|
public enum GatewayAuthSource: String, Sendable {
|
||||||
case deviceToken = "device-token"
|
case deviceToken = "device-token"
|
||||||
case sharedToken = "shared-token"
|
case sharedToken = "shared-token"
|
||||||
|
case bootstrapToken = "bootstrap-token"
|
||||||
case password = "password"
|
case password = "password"
|
||||||
case none = "none"
|
case none = "none"
|
||||||
}
|
}
|
||||||
@ -131,6 +132,12 @@ private let defaultOperatorConnectScopes: [String] = [
|
|||||||
"operator.pairing",
|
"operator.pairing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nilIfEmpty: String? {
|
||||||
|
self.isEmpty ? nil : self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private enum GatewayConnectErrorCodes {
|
private enum GatewayConnectErrorCodes {
|
||||||
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
|
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
|
||||||
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
|
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
|
||||||
@ -154,6 +161,7 @@ public actor GatewayChannelActor {
|
|||||||
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
||||||
private var url: URL
|
private var url: URL
|
||||||
private var token: String?
|
private var token: String?
|
||||||
|
private var bootstrapToken: String?
|
||||||
private var password: String?
|
private var password: String?
|
||||||
private let session: WebSocketSessioning
|
private let session: WebSocketSessioning
|
||||||
private var backoffMs: Double = 500
|
private var backoffMs: Double = 500
|
||||||
@ -185,6 +193,7 @@ public actor GatewayChannelActor {
|
|||||||
public init(
|
public init(
|
||||||
url: URL,
|
url: URL,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String? = nil,
|
||||||
password: String? = nil,
|
password: String? = nil,
|
||||||
session: WebSocketSessionBox? = nil,
|
session: WebSocketSessionBox? = nil,
|
||||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
|
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
|
||||||
@ -193,6 +202,7 @@ public actor GatewayChannelActor {
|
|||||||
{
|
{
|
||||||
self.url = url
|
self.url = url
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self.bootstrapToken = bootstrapToken
|
||||||
self.password = password
|
self.password = password
|
||||||
self.session = session?.session ?? URLSession(configuration: .default)
|
self.session = session?.session ?? URLSession(configuration: .default)
|
||||||
self.pushHandler = pushHandler
|
self.pushHandler = pushHandler
|
||||||
@ -402,22 +412,29 @@ public actor GatewayChannelActor {
|
|||||||
(includeDeviceIdentity && identity != nil)
|
(includeDeviceIdentity && identity != nil)
|
||||||
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
|
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
|
||||||
: nil
|
: 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 =
|
let shouldUseDeviceRetryToken =
|
||||||
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
|
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
|
||||||
storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint()
|
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
||||||
if shouldUseDeviceRetryToken {
|
if shouldUseDeviceRetryToken {
|
||||||
self.pendingDeviceTokenRetry = false
|
self.pendingDeviceTokenRetry = false
|
||||||
}
|
}
|
||||||
// Keep shared credentials explicit when provided. Device token retry is attached
|
// Keep shared credentials explicit when provided. Device token retry is attached
|
||||||
// only on a bounded second attempt after token mismatch.
|
// 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 authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
||||||
let authSource: GatewayAuthSource
|
let authSource: GatewayAuthSource
|
||||||
if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
|
if authDeviceToken != nil || (explicitToken == nil && storedToken != nil) {
|
||||||
authSource = .deviceToken
|
authSource = .deviceToken
|
||||||
} else if authToken != nil {
|
} else if authToken != nil {
|
||||||
authSource = .sharedToken
|
authSource = .sharedToken
|
||||||
} else if self.password != nil {
|
} else if authBootstrapToken != nil {
|
||||||
|
authSource = .bootstrapToken
|
||||||
|
} else if explicitPassword != nil {
|
||||||
authSource = .password
|
authSource = .password
|
||||||
} else {
|
} else {
|
||||||
authSource = .none
|
authSource = .none
|
||||||
@ -430,7 +447,9 @@ public actor GatewayChannelActor {
|
|||||||
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
|
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
|
||||||
}
|
}
|
||||||
params["auth"] = ProtoAnyCodable(auth)
|
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)])
|
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||||
}
|
}
|
||||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
@ -443,7 +462,7 @@ public actor GatewayChannelActor {
|
|||||||
role: role,
|
role: role,
|
||||||
scopes: scopes,
|
scopes: scopes,
|
||||||
signedAtMs: signedAtMs,
|
signedAtMs: signedAtMs,
|
||||||
token: authToken,
|
token: authToken ?? authBootstrapToken,
|
||||||
nonce: connectNonce,
|
nonce: connectNonce,
|
||||||
platform: platform,
|
platform: platform,
|
||||||
deviceFamily: InstanceIdentity.deviceFamily)
|
deviceFamily: InstanceIdentity.deviceFamily)
|
||||||
@ -472,7 +491,7 @@ public actor GatewayChannelActor {
|
|||||||
} catch {
|
} catch {
|
||||||
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
|
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
|
||||||
error: error,
|
error: error,
|
||||||
explicitGatewayToken: self.token,
|
explicitGatewayToken: explicitToken,
|
||||||
storedToken: storedToken,
|
storedToken: storedToken,
|
||||||
attemptedDeviceTokenRetry: authDeviceToken != nil)
|
attemptedDeviceTokenRetry: authDeviceToken != nil)
|
||||||
if shouldRetryWithDeviceToken {
|
if shouldRetryWithDeviceToken {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ public enum GatewayConnectAuthDetailCode: String, Sendable {
|
|||||||
case authRequired = "AUTH_REQUIRED"
|
case authRequired = "AUTH_REQUIRED"
|
||||||
case authUnauthorized = "AUTH_UNAUTHORIZED"
|
case authUnauthorized = "AUTH_UNAUTHORIZED"
|
||||||
case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
|
case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
|
||||||
|
case authBootstrapTokenInvalid = "AUTH_BOOTSTRAP_TOKEN_INVALID"
|
||||||
case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
|
case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
|
||||||
case authTokenMissing = "AUTH_TOKEN_MISSING"
|
case authTokenMissing = "AUTH_TOKEN_MISSING"
|
||||||
case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
|
case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
|
||||||
@ -92,6 +93,7 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
|||||||
public var isNonRecoverable: Bool {
|
public var isNonRecoverable: Bool {
|
||||||
switch self.detail {
|
switch self.detail {
|
||||||
case .authTokenMissing,
|
case .authTokenMissing,
|
||||||
|
.authBootstrapTokenInvalid,
|
||||||
.authTokenNotConfigured,
|
.authTokenNotConfigured,
|
||||||
.authPasswordMissing,
|
.authPasswordMissing,
|
||||||
.authPasswordMismatch,
|
.authPasswordMismatch,
|
||||||
|
|||||||
@ -64,6 +64,7 @@ public actor GatewayNodeSession {
|
|||||||
private var channel: GatewayChannelActor?
|
private var channel: GatewayChannelActor?
|
||||||
private var activeURL: URL?
|
private var activeURL: URL?
|
||||||
private var activeToken: String?
|
private var activeToken: String?
|
||||||
|
private var activeBootstrapToken: String?
|
||||||
private var activePassword: String?
|
private var activePassword: String?
|
||||||
private var activeConnectOptionsKey: String?
|
private var activeConnectOptionsKey: String?
|
||||||
private var connectOptions: GatewayConnectOptions?
|
private var connectOptions: GatewayConnectOptions?
|
||||||
@ -194,6 +195,7 @@ public actor GatewayNodeSession {
|
|||||||
public func connect(
|
public func connect(
|
||||||
url: URL,
|
url: URL,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
connectOptions: GatewayConnectOptions,
|
connectOptions: GatewayConnectOptions,
|
||||||
sessionBox: WebSocketSessionBox?,
|
sessionBox: WebSocketSessionBox?,
|
||||||
@ -204,6 +206,7 @@ public actor GatewayNodeSession {
|
|||||||
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
||||||
let shouldReconnect = self.activeURL != url ||
|
let shouldReconnect = self.activeURL != url ||
|
||||||
self.activeToken != token ||
|
self.activeToken != token ||
|
||||||
|
self.activeBootstrapToken != bootstrapToken ||
|
||||||
self.activePassword != password ||
|
self.activePassword != password ||
|
||||||
self.activeConnectOptionsKey != nextOptionsKey ||
|
self.activeConnectOptionsKey != nextOptionsKey ||
|
||||||
self.channel == nil
|
self.channel == nil
|
||||||
@ -221,6 +224,7 @@ public actor GatewayNodeSession {
|
|||||||
let channel = GatewayChannelActor(
|
let channel = GatewayChannelActor(
|
||||||
url: url,
|
url: url,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
session: sessionBox,
|
session: sessionBox,
|
||||||
pushHandler: { [weak self] push in
|
pushHandler: { [weak self] push in
|
||||||
@ -233,6 +237,7 @@ public actor GatewayNodeSession {
|
|||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.activeURL = url
|
self.activeURL = url
|
||||||
self.activeToken = token
|
self.activeToken = token
|
||||||
|
self.activeBootstrapToken = bootstrapToken
|
||||||
self.activePassword = password
|
self.activePassword = password
|
||||||
self.activeConnectOptionsKey = nextOptionsKey
|
self.activeConnectOptionsKey = nextOptionsKey
|
||||||
}
|
}
|
||||||
@ -257,6 +262,7 @@ public actor GatewayNodeSession {
|
|||||||
self.channel = nil
|
self.channel = nil
|
||||||
self.activeURL = nil
|
self.activeURL = nil
|
||||||
self.activeToken = nil
|
self.activeToken = nil
|
||||||
|
self.activeBootstrapToken = nil
|
||||||
self.activePassword = nil
|
self.activePassword = nil
|
||||||
self.activeConnectOptionsKey = nil
|
self.activeConnectOptionsKey = nil
|
||||||
self.hasEverConnected = false
|
self.hasEverConnected = false
|
||||||
|
|||||||
@ -20,11 +20,17 @@ import Testing
|
|||||||
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
|
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
|
||||||
#expect(
|
#expect(
|
||||||
DeepLinkParser.parse(url) == .gateway(
|
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() {
|
@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)
|
let encoded = Data(payload.utf8)
|
||||||
.base64EncodedString()
|
.base64EncodedString()
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
@ -34,7 +40,7 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func setupCodeRejectsInsecurePrefixBypassHost() {
|
@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)
|
let encoded = Data(payload.utf8)
|
||||||
.base64EncodedString()
|
.base64EncodedString()
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
@ -44,7 +50,7 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func setupCodeAllowsLoopbackWs() {
|
@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)
|
let encoded = Data(payload.utf8)
|
||||||
.base64EncodedString()
|
.base64EncodedString()
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
@ -55,7 +61,8 @@ import Testing
|
|||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 18789,
|
port: 18789,
|
||||||
tls: false,
|
tls: false,
|
||||||
token: "tok",
|
bootstrapToken: "tok",
|
||||||
|
token: nil,
|
||||||
password: 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(
|
try await gateway.connect(
|
||||||
url: URL(string: "ws://example.invalid")!,
|
url: URL(string: "ws://example.invalid")!,
|
||||||
token: nil,
|
token: nil,
|
||||||
|
bootstrapToken: nil,
|
||||||
password: nil,
|
password: nil,
|
||||||
connectOptions: options,
|
connectOptions: options,
|
||||||
sessionBox: WebSocketSessionBox(session: session),
|
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:
|
The setup code is a base64-encoded JSON payload that contains:
|
||||||
|
|
||||||
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`)
|
- `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.
|
Treat the setup code like a password while it is valid.
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ openclaw qr
|
|||||||
openclaw qr --setup-code-only
|
openclaw qr --setup-code-only
|
||||||
openclaw qr --json
|
openclaw qr --json
|
||||||
openclaw qr --remote
|
openclaw qr --remote
|
||||||
openclaw qr --url wss://gateway.example/ws --token '<token>'
|
openclaw qr --url wss://gateway.example/ws
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## 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
|
- `--remote`: use `gateway.remote.url` plus remote token/password from config
|
||||||
- `--url <url>`: override gateway URL used in payload
|
- `--url <url>`: override gateway URL used in payload
|
||||||
- `--public-url <url>`: override public URL used in payload
|
- `--public-url <url>`: override public URL used in payload
|
||||||
- `--token <token>`: override gateway token for payload
|
- `--token <token>`: override which gateway token the bootstrap flow authenticates against
|
||||||
- `--password <password>`: override gateway password for payload
|
- `--password <password>`: override which gateway password the bootstrap flow authenticates against
|
||||||
- `--setup-code-only`: print only setup code
|
- `--setup-code-only`: print only setup code
|
||||||
- `--no-ascii`: skip ASCII QR rendering
|
- `--no-ascii`: skip ASCII QR rendering
|
||||||
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`)
|
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`)
|
||||||
@ -34,6 +34,7 @@ openclaw qr --url wss://gateway.example/ws --token '<token>'
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `--token` and `--password` are mutually exclusive.
|
- `--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.
|
- 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:
|
- 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).
|
- `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 type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair";
|
||||||
import {
|
import {
|
||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
|
issueDeviceBootstrapToken,
|
||||||
listDevicePairing,
|
listDevicePairing,
|
||||||
resolveGatewayBindUrl,
|
resolveGatewayBindUrl,
|
||||||
runPluginCommandWithTimeout,
|
runPluginCommandWithTimeout,
|
||||||
@ -31,8 +32,7 @@ type DevicePairPluginConfig = {
|
|||||||
|
|
||||||
type SetupPayload = {
|
type SetupPayload = {
|
||||||
url: string;
|
url: string;
|
||||||
token?: string;
|
bootstrapToken: string;
|
||||||
password?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolveUrlResult = {
|
type ResolveUrlResult = {
|
||||||
@ -405,8 +405,14 @@ export default function register(api: OpenClawPluginApi) {
|
|||||||
|
|
||||||
const payload: SetupPayload = {
|
const payload: SetupPayload = {
|
||||||
url: urlResult.url,
|
url: urlResult.url,
|
||||||
token: auth.token,
|
bootstrapToken: (
|
||||||
password: auth.password,
|
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") {
|
if (action === "qr") {
|
||||||
|
|||||||
@ -27,6 +27,12 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWi
|
|||||||
vi.mock("./command-secret-gateway.js", () => ({
|
vi.mock("./command-secret-gateway.js", () => ({
|
||||||
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../infra/device-bootstrap.js", () => ({
|
||||||
|
issueDeviceBootstrapToken: vi.fn(async () => ({
|
||||||
|
token: "bootstrap-123",
|
||||||
|
expiresAtMs: 123,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
vi.mock("qrcode-terminal", () => ({
|
vi.mock("qrcode-terminal", () => ({
|
||||||
default: {
|
default: {
|
||||||
generate: mocks.qrGenerate,
|
generate: mocks.qrGenerate,
|
||||||
@ -156,7 +162,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
token: "tok",
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
expect(qrGenerate).not.toHaveBeenCalled();
|
expect(qrGenerate).not.toHaveBeenCalled();
|
||||||
@ -194,7 +200,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
token: "override-token",
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
});
|
});
|
||||||
@ -210,7 +216,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
token: "override-token",
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
});
|
});
|
||||||
@ -227,7 +233,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
password: "local-password-secret", // pragma: allowlist secret
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
||||||
@ -245,7 +251,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
password: "password-from-env", // pragma: allowlist secret
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
||||||
@ -264,7 +270,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
token: "token-123",
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
||||||
@ -282,7 +288,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "ws://gateway.local:18789",
|
url: "ws://gateway.local:18789",
|
||||||
password: "inferred-password", // pragma: allowlist secret
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
||||||
@ -332,7 +338,7 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "wss://remote.example.com:444",
|
url: "wss://remote.example.com:444",
|
||||||
token: "remote-tok",
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||||
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
||||||
@ -375,7 +381,7 @@ describe("registerQrCli", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
const expected = encodePairingSetupCode({
|
const expected = encodePairingSetupCode({
|
||||||
url: "wss://remote.example.com:444",
|
url: "wss://remote.example.com:444",
|
||||||
token: "remote-tok",
|
bootstrapToken: "bootstrap-123",
|
||||||
});
|
});
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
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 padded = setupCode.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
const padLength = (4 - (padded.length % 4)) % 4;
|
const padLength = (4 - (padded.length % 4)) % 4;
|
||||||
const normalized = padded + "=".repeat(padLength);
|
const normalized = padded + "=".repeat(padLength);
|
||||||
const json = Buffer.from(normalized, "base64").toString("utf8");
|
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> {
|
async function runCli(args: string[]): Promise<void> {
|
||||||
@ -126,7 +136,8 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
|
|||||||
expect(setupCode).toBeTruthy();
|
expect(setupCode).toBeTruthy();
|
||||||
const payload = decodeSetupCode(setupCode ?? "");
|
const payload = decodeSetupCode(setupCode ?? "");
|
||||||
expect(payload.url).toBe("ws://gateway.local:18789");
|
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([]);
|
expect(runtimeErrors).toEqual([]);
|
||||||
|
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
|
|||||||
@ -39,7 +39,14 @@ export type ResolvedGatewayAuth = {
|
|||||||
|
|
||||||
export type GatewayAuthResult = {
|
export type GatewayAuthResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy";
|
method?:
|
||||||
|
| "none"
|
||||||
|
| "token"
|
||||||
|
| "password"
|
||||||
|
| "tailscale"
|
||||||
|
| "device-token"
|
||||||
|
| "bootstrap-token"
|
||||||
|
| "trusted-proxy";
|
||||||
user?: string;
|
user?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
/** Present when the request was blocked by the rate limiter. */
|
/** Present when the request was blocked by the rate limiter. */
|
||||||
|
|||||||
@ -335,6 +335,7 @@ describe("GatewayClient connect auth payload", () => {
|
|||||||
params?: {
|
params?: {
|
||||||
auth?: {
|
auth?: {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
bootstrapToken?: string;
|
||||||
deviceToken?: string;
|
deviceToken?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
@ -410,6 +411,26 @@ describe("GatewayClient connect auth payload", () => {
|
|||||||
client.stop();
|
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", () => {
|
it("prefers explicit deviceToken over stored device token", () => {
|
||||||
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
|
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
|
||||||
const client = new GatewayClient({
|
const client = new GatewayClient({
|
||||||
|
|||||||
@ -69,6 +69,7 @@ export type GatewayClientOptions = {
|
|||||||
connectDelayMs?: number;
|
connectDelayMs?: number;
|
||||||
tickWatchMinIntervalMs?: number;
|
tickWatchMinIntervalMs?: number;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
bootstrapToken?: string;
|
||||||
deviceToken?: string;
|
deviceToken?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
@ -281,6 +282,7 @@ export class GatewayClient {
|
|||||||
}
|
}
|
||||||
const role = this.opts.role ?? "operator";
|
const role = this.opts.role ?? "operator";
|
||||||
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
const explicitGatewayToken = this.opts.token?.trim() || undefined;
|
||||||
|
const explicitBootstrapToken = this.opts.bootstrapToken?.trim() || undefined;
|
||||||
const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined;
|
const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined;
|
||||||
const storedToken = this.opts.deviceIdentity
|
const storedToken = this.opts.deviceIdentity
|
||||||
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
|
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
|
||||||
@ -294,21 +296,27 @@ export class GatewayClient {
|
|||||||
if (shouldUseDeviceRetryToken) {
|
if (shouldUseDeviceRetryToken) {
|
||||||
this.pendingDeviceTokenRetry = false;
|
this.pendingDeviceTokenRetry = false;
|
||||||
}
|
}
|
||||||
// Keep shared gateway credentials explicit. Persisted per-device tokens only
|
// Shared gateway credentials stay explicit. Bootstrap tokens are different:
|
||||||
// participate when no explicit shared token/password is provided.
|
// once a role-scoped device token exists, it should take precedence so the
|
||||||
|
// temporary bootstrap secret falls out of active use.
|
||||||
const resolvedDeviceToken =
|
const resolvedDeviceToken =
|
||||||
explicitDeviceToken ??
|
explicitDeviceToken ??
|
||||||
(shouldUseDeviceRetryToken || !(explicitGatewayToken || this.opts.password?.trim())
|
(shouldUseDeviceRetryToken ||
|
||||||
|
(!(explicitGatewayToken || this.opts.password?.trim()) &&
|
||||||
|
(!explicitBootstrapToken || Boolean(storedToken)))
|
||||||
? (storedToken ?? undefined)
|
? (storedToken ?? undefined)
|
||||||
: undefined);
|
: undefined);
|
||||||
// Legacy compatibility: keep `auth.token` populated for device-token auth when
|
// Legacy compatibility: keep `auth.token` populated for device-token auth when
|
||||||
// no explicit shared token is present.
|
// no explicit shared token is present.
|
||||||
const authToken = explicitGatewayToken ?? resolvedDeviceToken;
|
const authToken = explicitGatewayToken ?? resolvedDeviceToken;
|
||||||
|
const authBootstrapToken =
|
||||||
|
!explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined;
|
||||||
const authPassword = this.opts.password?.trim() || undefined;
|
const authPassword = this.opts.password?.trim() || undefined;
|
||||||
const auth =
|
const auth =
|
||||||
authToken || authPassword || resolvedDeviceToken
|
authToken || authBootstrapToken || authPassword || resolvedDeviceToken
|
||||||
? {
|
? {
|
||||||
token: authToken,
|
token: authToken,
|
||||||
|
bootstrapToken: authBootstrapToken,
|
||||||
deviceToken: resolvedDeviceToken,
|
deviceToken: resolvedDeviceToken,
|
||||||
password: authPassword,
|
password: authPassword,
|
||||||
}
|
}
|
||||||
@ -327,7 +335,7 @@ export class GatewayClient {
|
|||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
signedAtMs,
|
signedAtMs,
|
||||||
token: authToken ?? null,
|
token: authToken ?? authBootstrapToken ?? null,
|
||||||
nonce,
|
nonce,
|
||||||
platform,
|
platform,
|
||||||
deviceFamily: this.opts.deviceFamily,
|
deviceFamily: this.opts.deviceFamily,
|
||||||
@ -420,6 +428,7 @@ export class GatewayClient {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
|
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
|
||||||
|
detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
|
||||||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
|
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
|
||||||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
|
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
|
||||||
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const ConnectErrorDetailCodes = {
|
|||||||
AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING", // pragma: allowlist secret
|
AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING", // pragma: allowlist secret
|
||||||
AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH", // pragma: allowlist secret
|
AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH", // pragma: allowlist secret
|
||||||
AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED", // 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_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH",
|
||||||
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
|
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
|
||||||
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
|
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
|
||||||
@ -64,6 +65,8 @@ export function resolveAuthConnectErrorDetailCode(
|
|||||||
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH;
|
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH;
|
||||||
case "password_missing_config":
|
case "password_missing_config":
|
||||||
return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED;
|
return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED;
|
||||||
|
case "bootstrap_token_invalid":
|
||||||
|
return ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID;
|
||||||
case "tailscale_user_missing":
|
case "tailscale_user_missing":
|
||||||
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING;
|
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING;
|
||||||
case "tailscale_proxy_missing":
|
case "tailscale_proxy_missing":
|
||||||
|
|||||||
@ -56,6 +56,7 @@ export const ConnectParamsSchema = Type.Object(
|
|||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
token: Type.Optional(Type.String()),
|
token: Type.Optional(Type.String()),
|
||||||
|
bootstrapToken: Type.Optional(Type.String()),
|
||||||
deviceToken: Type.Optional(Type.String()),
|
deviceToken: Type.Optional(Type.String()),
|
||||||
password: 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", () => {
|
it("blocks reconnect for AUTH_PASSWORD_MISSING", () => {
|
||||||
expect(
|
expect(
|
||||||
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)),
|
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";
|
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
|
||||||
|
|
||||||
type VerifyDeviceTokenFn = Parameters<typeof resolveConnectAuthDecision>[0]["verifyDeviceToken"];
|
type VerifyDeviceTokenFn = Parameters<typeof resolveConnectAuthDecision>[0]["verifyDeviceToken"];
|
||||||
|
type VerifyBootstrapTokenFn = Parameters<
|
||||||
|
typeof resolveConnectAuthDecision
|
||||||
|
>[0]["verifyBootstrapToken"];
|
||||||
|
|
||||||
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
|
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
|
||||||
limiter: AuthRateLimiter;
|
limiter: AuthRateLimiter;
|
||||||
@ -38,6 +41,7 @@ function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthStat
|
|||||||
|
|
||||||
async function resolveDeviceTokenDecision(params: {
|
async function resolveDeviceTokenDecision(params: {
|
||||||
verifyDeviceToken: VerifyDeviceTokenFn;
|
verifyDeviceToken: VerifyDeviceTokenFn;
|
||||||
|
verifyBootstrapToken?: VerifyBootstrapTokenFn;
|
||||||
stateOverrides?: Partial<ConnectAuthState>;
|
stateOverrides?: Partial<ConnectAuthState>;
|
||||||
rateLimiter?: AuthRateLimiter;
|
rateLimiter?: AuthRateLimiter;
|
||||||
clientIp?: string;
|
clientIp?: string;
|
||||||
@ -46,8 +50,12 @@ async function resolveDeviceTokenDecision(params: {
|
|||||||
state: createBaseState(params.stateOverrides),
|
state: createBaseState(params.stateOverrides),
|
||||||
hasDeviceIdentity: true,
|
hasDeviceIdentity: true,
|
||||||
deviceId: "dev-1",
|
deviceId: "dev-1",
|
||||||
|
publicKey: "pub-1",
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: ["operator.read"],
|
scopes: ["operator.read"],
|
||||||
|
verifyBootstrapToken:
|
||||||
|
params.verifyBootstrapToken ??
|
||||||
|
(async () => ({ ok: false, reason: "bootstrap_token_invalid" })),
|
||||||
verifyDeviceToken: params.verifyDeviceToken,
|
verifyDeviceToken: params.verifyDeviceToken,
|
||||||
...(params.rateLimiter ? { rateLimiter: params.rateLimiter } : {}),
|
...(params.rateLimiter ? { rateLimiter: params.rateLimiter } : {}),
|
||||||
...(params.clientIp ? { clientIp: params.clientIp } : {}),
|
...(params.clientIp ? { clientIp: params.clientIp } : {}),
|
||||||
@ -57,16 +65,23 @@ async function resolveDeviceTokenDecision(params: {
|
|||||||
describe("resolveConnectAuthDecision", () => {
|
describe("resolveConnectAuthDecision", () => {
|
||||||
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
|
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
|
||||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false }));
|
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false }));
|
||||||
|
const verifyBootstrapToken = vi.fn<VerifyBootstrapTokenFn>(async () => ({
|
||||||
|
ok: false,
|
||||||
|
reason: "bootstrap_token_invalid",
|
||||||
|
}));
|
||||||
const decision = await resolveConnectAuthDecision({
|
const decision = await resolveConnectAuthDecision({
|
||||||
state: createBaseState(),
|
state: createBaseState(),
|
||||||
hasDeviceIdentity: true,
|
hasDeviceIdentity: true,
|
||||||
deviceId: "dev-1",
|
deviceId: "dev-1",
|
||||||
|
publicKey: "pub-1",
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: ["operator.read"],
|
scopes: ["operator.read"],
|
||||||
|
verifyBootstrapToken,
|
||||||
verifyDeviceToken,
|
verifyDeviceToken,
|
||||||
});
|
});
|
||||||
expect(decision.authOk).toBe(false);
|
expect(decision.authOk).toBe(false);
|
||||||
expect(decision.authResult.reason).toBe("token_mismatch");
|
expect(decision.authResult.reason).toBe("token_mismatch");
|
||||||
|
expect(verifyBootstrapToken).not.toHaveBeenCalled();
|
||||||
expect(verifyDeviceToken).toHaveBeenCalledOnce();
|
expect(verifyDeviceToken).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -78,8 +93,10 @@ describe("resolveConnectAuthDecision", () => {
|
|||||||
}),
|
}),
|
||||||
hasDeviceIdentity: true,
|
hasDeviceIdentity: true,
|
||||||
deviceId: "dev-1",
|
deviceId: "dev-1",
|
||||||
|
publicKey: "pub-1",
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: ["operator.read"],
|
scopes: ["operator.read"],
|
||||||
|
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
|
||||||
verifyDeviceToken,
|
verifyDeviceToken,
|
||||||
});
|
});
|
||||||
expect(decision.authOk).toBe(false);
|
expect(decision.authOk).toBe(false);
|
||||||
@ -100,6 +117,44 @@ describe("resolveConnectAuthDecision", () => {
|
|||||||
expect(rateLimiter.reset).toHaveBeenCalledOnce();
|
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 () => {
|
it("returns rate-limited auth result without verifying device token", async () => {
|
||||||
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
|
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
|
||||||
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
|
||||||
@ -123,8 +178,10 @@ describe("resolveConnectAuthDecision", () => {
|
|||||||
}),
|
}),
|
||||||
hasDeviceIdentity: true,
|
hasDeviceIdentity: true,
|
||||||
deviceId: "dev-1",
|
deviceId: "dev-1",
|
||||||
|
publicKey: "pub-1",
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: [],
|
scopes: [],
|
||||||
|
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
|
||||||
verifyDeviceToken,
|
verifyDeviceToken,
|
||||||
});
|
});
|
||||||
expect(decision.authOk).toBe(true);
|
expect(decision.authOk).toBe(true);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
|
|
||||||
type HandshakeConnectAuth = {
|
type HandshakeConnectAuth = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
bootstrapToken?: string;
|
||||||
deviceToken?: string;
|
deviceToken?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
@ -26,11 +27,13 @@ export type ConnectAuthState = {
|
|||||||
authMethod: GatewayAuthResult["method"];
|
authMethod: GatewayAuthResult["method"];
|
||||||
sharedAuthOk: boolean;
|
sharedAuthOk: boolean;
|
||||||
sharedAuthProvided: boolean;
|
sharedAuthProvided: boolean;
|
||||||
|
bootstrapTokenCandidate?: string;
|
||||||
deviceTokenCandidate?: string;
|
deviceTokenCandidate?: string;
|
||||||
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
|
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VerifyDeviceTokenResult = { ok: boolean };
|
type VerifyDeviceTokenResult = { ok: boolean };
|
||||||
|
type VerifyBootstrapTokenResult = { ok: boolean; reason?: string };
|
||||||
|
|
||||||
export type ConnectAuthDecision = {
|
export type ConnectAuthDecision = {
|
||||||
authResult: GatewayAuthResult;
|
authResult: GatewayAuthResult;
|
||||||
@ -72,6 +75,12 @@ function resolveDeviceTokenCandidate(connectAuth: HandshakeConnectAuth | null |
|
|||||||
return { token: fallbackToken, source: "shared-token-fallback" };
|
return { token: fallbackToken, source: "shared-token-fallback" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBootstrapTokenCandidate(
|
||||||
|
connectAuth: HandshakeConnectAuth | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
return trimToUndefined(connectAuth?.bootstrapToken);
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveConnectAuthState(params: {
|
export async function resolveConnectAuthState(params: {
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
connectAuth: HandshakeConnectAuth | null | undefined;
|
connectAuth: HandshakeConnectAuth | null | undefined;
|
||||||
@ -84,6 +93,9 @@ export async function resolveConnectAuthState(params: {
|
|||||||
}): Promise<ConnectAuthState> {
|
}): Promise<ConnectAuthState> {
|
||||||
const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth);
|
const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth);
|
||||||
const sharedAuthProvided = Boolean(sharedConnectAuth);
|
const sharedAuthProvided = Boolean(sharedConnectAuth);
|
||||||
|
const bootstrapTokenCandidate = params.hasDeviceIdentity
|
||||||
|
? resolveBootstrapTokenCandidate(params.connectAuth)
|
||||||
|
: undefined;
|
||||||
const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } =
|
const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } =
|
||||||
params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {};
|
params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {};
|
||||||
const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate);
|
const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate);
|
||||||
@ -148,6 +160,7 @@ export async function resolveConnectAuthState(params: {
|
|||||||
authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"),
|
authResult.method ?? (params.resolvedAuth.mode === "password" ? "password" : "token"),
|
||||||
sharedAuthOk,
|
sharedAuthOk,
|
||||||
sharedAuthProvided,
|
sharedAuthProvided,
|
||||||
|
bootstrapTokenCandidate,
|
||||||
deviceTokenCandidate,
|
deviceTokenCandidate,
|
||||||
deviceTokenCandidateSource,
|
deviceTokenCandidateSource,
|
||||||
};
|
};
|
||||||
@ -157,10 +170,18 @@ export async function resolveConnectAuthDecision(params: {
|
|||||||
state: ConnectAuthState;
|
state: ConnectAuthState;
|
||||||
hasDeviceIdentity: boolean;
|
hasDeviceIdentity: boolean;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
|
publicKey?: string;
|
||||||
role: string;
|
role: string;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
rateLimiter?: AuthRateLimiter;
|
rateLimiter?: AuthRateLimiter;
|
||||||
clientIp?: string;
|
clientIp?: string;
|
||||||
|
verifyBootstrapToken: (params: {
|
||||||
|
deviceId: string;
|
||||||
|
publicKey: string;
|
||||||
|
token: string;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
}) => Promise<VerifyBootstrapTokenResult>;
|
||||||
verifyDeviceToken: (params: {
|
verifyDeviceToken: (params: {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
token: string;
|
token: string;
|
||||||
@ -172,6 +193,29 @@ export async function resolveConnectAuthDecision(params: {
|
|||||||
let authOk = params.state.authOk;
|
let authOk = params.state.authOk;
|
||||||
let authMethod = params.state.authMethod;
|
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;
|
const deviceTokenCandidate = params.state.deviceTokenCandidate;
|
||||||
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
|
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
|
||||||
return { authResult, authOk, authMethod };
|
return { authResult, authOk, authMethod };
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-chan
|
|||||||
import type { ResolvedGatewayAuth } from "../../auth.js";
|
import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||||
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.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: {
|
export function formatGatewayAuthFailureMessage(params: {
|
||||||
authMode: ResolvedGatewayAuth["mode"];
|
authMode: ResolvedGatewayAuth["mode"];
|
||||||
@ -38,6 +38,8 @@ export function formatGatewayAuthFailureMessage(params: {
|
|||||||
return `unauthorized: gateway password mismatch (${passwordHint})`;
|
return `unauthorized: gateway password mismatch (${passwordHint})`;
|
||||||
case "password_missing_config":
|
case "password_missing_config":
|
||||||
return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)";
|
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":
|
case "tailscale_user_missing":
|
||||||
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
|
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
|
||||||
case "tailscale_proxy_missing":
|
case "tailscale_proxy_missing":
|
||||||
@ -60,6 +62,9 @@ export function formatGatewayAuthFailureMessage(params: {
|
|||||||
if (authMode === "token" && authProvided === "device-token") {
|
if (authMode === "token" && authProvided === "device-token") {
|
||||||
return "unauthorized: device token rejected (pair/repair this device, or provide gateway 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") {
|
if (authMode === "password" && authProvided === "none") {
|
||||||
return `unauthorized: gateway password missing (${passwordHint})`;
|
return `unauthorized: gateway password missing (${passwordHint})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { IncomingMessage } from "node:http";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
import { loadConfig } from "../../../config/config.js";
|
import { loadConfig } from "../../../config/config.js";
|
||||||
|
import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js";
|
||||||
import {
|
import {
|
||||||
deriveDeviceIdFromPublicKey,
|
deriveDeviceIdFromPublicKey,
|
||||||
normalizeDevicePublicKeyBase64Url,
|
normalizeDevicePublicKeyBase64Url,
|
||||||
@ -186,7 +187,11 @@ function resolveDeviceSignaturePayloadVersion(params: {
|
|||||||
role: params.role,
|
role: params.role,
|
||||||
scopes: params.scopes,
|
scopes: params.scopes,
|
||||||
signedAtMs: params.signedAtMs,
|
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,
|
nonce: params.nonce,
|
||||||
platform: params.connectParams.client.platform,
|
platform: params.connectParams.client.platform,
|
||||||
deviceFamily: params.connectParams.client.deviceFamily,
|
deviceFamily: params.connectParams.client.deviceFamily,
|
||||||
@ -202,7 +207,11 @@ function resolveDeviceSignaturePayloadVersion(params: {
|
|||||||
role: params.role,
|
role: params.role,
|
||||||
scopes: params.scopes,
|
scopes: params.scopes,
|
||||||
signedAtMs: params.signedAtMs,
|
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,
|
nonce: params.nonce,
|
||||||
});
|
});
|
||||||
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
|
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
|
||||||
@ -566,6 +575,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
authOk,
|
authOk,
|
||||||
authMethod,
|
authMethod,
|
||||||
sharedAuthOk,
|
sharedAuthOk,
|
||||||
|
bootstrapTokenCandidate,
|
||||||
deviceTokenCandidate,
|
deviceTokenCandidate,
|
||||||
deviceTokenCandidateSource,
|
deviceTokenCandidateSource,
|
||||||
} = await resolveConnectAuthState({
|
} = await resolveConnectAuthState({
|
||||||
@ -610,9 +620,11 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
? "password"
|
? "password"
|
||||||
: connectParams.auth?.token
|
: connectParams.auth?.token
|
||||||
? "token"
|
? "token"
|
||||||
: connectParams.auth?.deviceToken
|
: connectParams.auth?.bootstrapToken
|
||||||
? "device-token"
|
? "bootstrap-token"
|
||||||
: "none",
|
: connectParams.auth?.deviceToken
|
||||||
|
? "device-token"
|
||||||
|
: "none",
|
||||||
authReason: failedAuth.reason,
|
authReason: failedAuth.reason,
|
||||||
allowTailscale: resolvedAuth.allowTailscale,
|
allowTailscale: resolvedAuth.allowTailscale,
|
||||||
});
|
});
|
||||||
@ -623,9 +635,11 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
? "password"
|
? "password"
|
||||||
: connectParams.auth?.token
|
: connectParams.auth?.token
|
||||||
? "token"
|
? "token"
|
||||||
: connectParams.auth?.deviceToken
|
: connectParams.auth?.bootstrapToken
|
||||||
? "device-token"
|
? "bootstrap-token"
|
||||||
: "none";
|
: connectParams.auth?.deviceToken
|
||||||
|
? "device-token"
|
||||||
|
: "none";
|
||||||
const authMessage = formatGatewayAuthFailureMessage({
|
const authMessage = formatGatewayAuthFailureMessage({
|
||||||
authMode: resolvedAuth.mode,
|
authMode: resolvedAuth.mode,
|
||||||
authProvided,
|
authProvided,
|
||||||
@ -774,15 +788,25 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
authMethod,
|
authMethod,
|
||||||
sharedAuthOk,
|
sharedAuthOk,
|
||||||
sharedAuthProvided: hasSharedAuth,
|
sharedAuthProvided: hasSharedAuth,
|
||||||
|
bootstrapTokenCandidate,
|
||||||
deviceTokenCandidate,
|
deviceTokenCandidate,
|
||||||
deviceTokenCandidateSource,
|
deviceTokenCandidateSource,
|
||||||
},
|
},
|
||||||
hasDeviceIdentity: Boolean(device),
|
hasDeviceIdentity: Boolean(device),
|
||||||
deviceId: device?.id,
|
deviceId: device?.id,
|
||||||
|
publicKey: device?.publicKey,
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
rateLimiter: authRateLimiter,
|
rateLimiter: authRateLimiter,
|
||||||
clientIp: browserRateLimitClientIp,
|
clientIp: browserRateLimitClientIp,
|
||||||
|
verifyBootstrapToken: async ({ deviceId, publicKey, token, role, scopes }) =>
|
||||||
|
await verifyDeviceBootstrapToken({
|
||||||
|
deviceId,
|
||||||
|
publicKey,
|
||||||
|
token,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
}),
|
||||||
verifyDeviceToken,
|
verifyDeviceToken,
|
||||||
}));
|
}));
|
||||||
if (!authOk) {
|
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 type { SecretInput } from "../config/types.secrets.js";
|
||||||
import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "./setup-code.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", () => {
|
describe("pairing setup code", () => {
|
||||||
function createTailnetDnsRunner() {
|
function createTailnetDnsRunner() {
|
||||||
return vi.fn(async () => ({
|
return vi.fn(async () => ({
|
||||||
@ -25,10 +32,12 @@ describe("pairing setup code", () => {
|
|||||||
it("encodes payload as base64url JSON", () => {
|
it("encodes payload as base64url JSON", () => {
|
||||||
const code = encodePairingSetupCode({
|
const code = encodePairingSetupCode({
|
||||||
url: "wss://gateway.example.com:443",
|
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 () => {
|
it("resolves custom bind + token auth", async () => {
|
||||||
@ -45,8 +54,7 @@ describe("pairing setup code", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
payload: {
|
payload: {
|
||||||
url: "ws://gateway.local:19001",
|
url: "ws://gateway.local:19001",
|
||||||
token: "tok_123",
|
bootstrapToken: "bootstrap-123",
|
||||||
password: undefined,
|
|
||||||
},
|
},
|
||||||
authLabel: "token",
|
authLabel: "token",
|
||||||
urlSource: "gateway.bind=custom",
|
urlSource: "gateway.bind=custom",
|
||||||
@ -81,7 +89,7 @@ describe("pairing setup code", () => {
|
|||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
throw new Error("expected setup resolution to succeed");
|
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");
|
expect(resolved.authLabel).toBe("password");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,7 +121,7 @@ describe("pairing setup code", () => {
|
|||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
throw new Error("expected setup resolution to succeed");
|
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");
|
expect(resolved.authLabel).toBe("password");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -145,7 +153,7 @@ describe("pairing setup code", () => {
|
|||||||
throw new Error("expected setup resolution to succeed");
|
throw new Error("expected setup resolution to succeed");
|
||||||
}
|
}
|
||||||
expect(resolved.authLabel).toBe("token");
|
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 () => {
|
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");
|
throw new Error("expected setup resolution to succeed");
|
||||||
}
|
}
|
||||||
expect(resolved.authLabel).toBe("token");
|
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 () => {
|
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");
|
throw new Error("expected setup resolution to succeed");
|
||||||
}
|
}
|
||||||
expect(resolved.authLabel).toBe("password");
|
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 () => {
|
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");
|
throw new Error("expected setup resolution to succeed");
|
||||||
}
|
}
|
||||||
expect(resolved.authLabel).toBe("password");
|
expect(resolved.authLabel).toBe("password");
|
||||||
expect(resolved.payload.token).toBeUndefined();
|
expect(resolved.payload.bootstrapToken).toBe("bootstrap-123");
|
||||||
expect(resolved.payload.password).toBe("password-from-env");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires explicit auth mode when token and password are both configured", async () => {
|
it("requires explicit auth mode when token and password are both configured", async () => {
|
||||||
@ -329,7 +336,7 @@ describe("pairing setup code", () => {
|
|||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
throw new Error("expected setup resolution to succeed");
|
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 () => {
|
it("errors when gateway is loopback only", async () => {
|
||||||
@ -366,8 +373,7 @@ describe("pairing setup code", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
payload: {
|
payload: {
|
||||||
url: "wss://mb-server.tailnet.ts.net",
|
url: "wss://mb-server.tailnet.ts.net",
|
||||||
token: undefined,
|
bootstrapToken: "bootstrap-123",
|
||||||
password: "secret",
|
|
||||||
},
|
},
|
||||||
authLabel: "password",
|
authLabel: "password",
|
||||||
urlSource: "gateway.tailscale.mode=serve",
|
urlSource: "gateway.tailscale.mode=serve",
|
||||||
@ -395,8 +401,7 @@ describe("pairing setup code", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
payload: {
|
payload: {
|
||||||
url: "wss://remote.example.com:444",
|
url: "wss://remote.example.com:444",
|
||||||
token: "tok_123",
|
bootstrapToken: "bootstrap-123",
|
||||||
password: undefined,
|
|
||||||
},
|
},
|
||||||
authLabel: "token",
|
authLabel: "token",
|
||||||
urlSource: "gateway.remote.url",
|
urlSource: "gateway.remote.url",
|
||||||
|
|||||||
@ -8,14 +8,14 @@ import {
|
|||||||
} from "../config/types.secrets.js";
|
} from "../config/types.secrets.js";
|
||||||
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
|
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
|
||||||
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.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 { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
|
||||||
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
|
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
|
||||||
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
|
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
|
||||||
|
|
||||||
export type PairingSetupPayload = {
|
export type PairingSetupPayload = {
|
||||||
url: string;
|
url: string;
|
||||||
token?: string;
|
bootstrapToken: string;
|
||||||
password?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PairingSetupCommandResult = {
|
export type PairingSetupCommandResult = {
|
||||||
@ -34,6 +34,7 @@ export type ResolvePairingSetupOptions = {
|
|||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
preferRemoteUrl?: boolean;
|
preferRemoteUrl?: boolean;
|
||||||
forceSecure?: boolean;
|
forceSecure?: boolean;
|
||||||
|
pairingBaseDir?: string;
|
||||||
runCommandWithTimeout?: PairingSetupCommandRunner;
|
runCommandWithTimeout?: PairingSetupCommandRunner;
|
||||||
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
|
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
|
||||||
};
|
};
|
||||||
@ -388,8 +389,11 @@ export async function resolvePairingSetupFromConfig(
|
|||||||
ok: true,
|
ok: true,
|
||||||
payload: {
|
payload: {
|
||||||
url: urlResult.url,
|
url: urlResult.url,
|
||||||
token: auth.token,
|
bootstrapToken: (
|
||||||
password: auth.password,
|
await issueDeviceBootstrapToken({
|
||||||
|
baseDir: options.pairingBaseDir,
|
||||||
|
})
|
||||||
|
).token,
|
||||||
},
|
},
|
||||||
authLabel: auth.label,
|
authLabel: auth.label,
|
||||||
urlSource: urlResult.source ?? "unknown",
|
urlSource: urlResult.source ?? "unknown",
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
// Keep this list additive and scoped to symbols used under extensions/device-pair.
|
// Keep this list additive and scoped to symbols used under extensions/device-pair.
|
||||||
|
|
||||||
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
|
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
|
||||||
|
export { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
|
||||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||||
export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
|
export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
|
||||||
export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
|
export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
|
||||||
|
|||||||
@ -69,6 +69,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
|
|||||||
const code = resolveGatewayErrorDetailCode(error);
|
const code = resolveGatewayErrorDetailCode(error);
|
||||||
return (
|
return (
|
||||||
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
|
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
|
||||||
|
code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||
|
||||||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
|
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
|
||||||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
|
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
|
||||||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user