fix(pairing): align mobile setup with secure endpoints

This commit is contained in:
Ayaan Zaidi
2026-04-03 12:55:11 +05:30
parent c6f95a0c37
commit acd5734aa9
18 changed files with 412 additions and 73 deletions

View File

@@ -16,6 +16,8 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewayDiscovery
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.*
import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
@@ -44,7 +46,7 @@ import java.util.concurrent.atomic.AtomicLong
class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
private val tlsFingerprintProbe: suspend (String, Int) -> String? = ::probeGatewayTlsFingerprint,
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
) {
data class GatewayConnectAuth(
val token: String?,
@@ -839,8 +841,9 @@ class NodeRuntime(
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
_statusText.value = "Verify gateway TLS fingerprint…"
scope.launch {
val fp = tlsFingerprintProbe(endpoint.host, endpoint.port) ?: run {
_statusText.value = "Failed: can't read TLS fingerprint"
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
val fp = tlsProbe.fingerprintSha256 ?: run {
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
return@launch
}
_pendingGatewayTrust.value =
@@ -888,6 +891,15 @@ class NodeRuntime(
_statusText.value = "Offline"
}
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String {
return when (failure) {
GatewayTlsProbeFailure.TLS_UNAVAILABLE ->
"Failed: remote mobile nodes require wss:// or Tailscale Serve. No TLS endpoint detected for this host."
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE, null ->
"Failed: couldn't reach a secure gateway endpoint. Remote mobile nodes require wss:// or Tailscale Serve."
}
}
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==

View File

@@ -3,7 +3,11 @@ package ai.openclaw.app.gateway
import android.annotation.SuppressLint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.EOFException
import java.net.ConnectException
import java.net.InetSocketAddress
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
@@ -12,6 +16,7 @@ import java.util.Locale
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.SNIHostName
@@ -32,6 +37,16 @@ data class GatewayTlsConfig(
val hostnameVerifier: HostnameVerifier,
)
enum class GatewayTlsProbeFailure {
TLS_UNAVAILABLE,
ENDPOINT_UNREACHABLE,
}
data class GatewayTlsProbeResult(
val fingerprintSha256: String? = null,
val failure: GatewayTlsProbeFailure? = null,
)
fun buildGatewayTlsConfig(
params: GatewayTlsParams?,
onStore: ((String) -> Unit)? = null,
@@ -85,10 +100,10 @@ suspend fun probeGatewayTlsFingerprint(
host: String,
port: Int,
timeoutMs: Int = 3_000,
): String? {
): GatewayTlsProbeResult {
val trimmedHost = host.trim()
if (trimmedHost.isEmpty()) return null
if (port !in 1..65535) return null
if (trimmedHost.isEmpty()) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
return withContext(Dispatchers.IO) {
val trustAll =
@@ -121,10 +136,21 @@ suspend fun probeGatewayTlsFingerprint(
}
socket.startHandshake()
val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
sha256Hex(cert.encoded)
} catch (_: Throwable) {
null
val cert =
socket.session.peerCertificates.firstOrNull() as? X509Certificate
?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
GatewayTlsProbeResult(fingerprintSha256 = sha256Hex(cert.encoded))
} catch (err: Throwable) {
val failure =
when (err) {
is SSLException,
is EOFException -> GatewayTlsProbeFailure.TLS_UNAVAILABLE
is ConnectException,
is SocketTimeoutException,
is UnknownHostException -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
}
GatewayTlsProbeResult(failure = failure)
} finally {
try {
socket.close()

View File

@@ -256,9 +256,23 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
if (config == null) {
validationText =
if (inputMode == ConnectInputMode.SetupCode) {
"Paste a valid setup code to connect."
val parsedSetup = decodeGatewaySetupCode(setupCode)
if (parsedSetup == null) {
"Paste a valid setup code to connect."
} else {
val parsedGateway = parseGatewayEndpointResult(parsedSetup.url)
gatewayEndpointValidationMessage(
parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.SETUP_CODE,
)
}
} else {
"Enter a valid manual host and port to connect."
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)
val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult)
gatewayEndpointValidationMessage(
parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.MANUAL,
)
}
return@Button
}
@@ -386,6 +400,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary)
CommandBlock("openclaw qr --setup-code-only")
CommandBlock("openclaw qr --json")
Text(
"Remote mobile nodes require wss:// or Tailscale Serve. ws:// is only for localhost or the Android emulator.",
style = mobileCaption1,
color = mobileTextSecondary,
)
if (inputMode == ConnectInputMode.SetupCode) {
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
@@ -468,7 +487,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = mobileHeadline, color = mobileText)
Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary)
Text(
"Required for remote hosts. Use Tailscale Serve or a wss:// gateway URL.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
Switch(
checked = manualTlsInput,

View File

@@ -33,7 +33,32 @@ internal data class GatewayConnectConfig(
val password: String,
)
internal enum class GatewayEndpointValidationError {
INVALID_URL,
INSECURE_REMOTE_URL,
}
internal enum class GatewayEndpointInputSource {
SETUP_CODE,
MANUAL,
QR_SCAN,
}
internal data class GatewayEndpointParseResult(
val config: GatewayEndpointConfig? = null,
val error: GatewayEndpointValidationError? = null,
)
internal data class GatewayScannedSetupCodeResult(
val setupCode: String? = null,
val error: GatewayEndpointValidationError? = null,
)
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
private const val remoteGatewaySecurityRule =
"Remote mobile nodes require wss:// or Tailscale Serve. ws:// is only for localhost or the Android emulator."
private const val remoteGatewaySecurityFix =
"Enable Tailscale Serve or expose a wss:// gateway URL."
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
@@ -50,7 +75,7 @@ internal fun resolveGatewayConnectConfig(
): GatewayConnectConfig? {
if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpoint(setup.url) ?: return null
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
val sharedToken =
when {
@@ -75,10 +100,10 @@ internal fun resolveGatewayConnectConfig(
}
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null
val parsed = parseGatewayEndpoint(manualUrl) ?: return null
val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null
val savedManualEndpoint =
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
?.let(::parseGatewayEndpoint)
?.let { parseGatewayEndpointResult(it).config }
val preserveBootstrapToken =
savedManualEndpoint != null &&
savedManualEndpoint.host == parsed.host &&
@@ -97,13 +122,19 @@ internal fun resolveGatewayConnectConfig(
}
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
return parseGatewayEndpointResult(rawInput).config
}
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
val raw = rawInput.trim()
if (raw.isEmpty()) return null
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val normalized = if (raw.contains("://")) raw else "https://$raw"
val uri = runCatching { URI(normalized) }.getOrNull() ?: return null
val uri =
runCatching { URI(normalized) }.getOrNull()
?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val host = uri.host?.trim()?.trim('[', ']').orEmpty()
if (host.isEmpty()) return null
if (host.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty()
val tls =
@@ -112,7 +143,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
"wss", "https" -> true
else -> true
}
if (!tls && !isLoopbackGatewayHost(host)) return null
if (!tls && !isLoopbackGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort =
when (scheme) {
"wss", "https" -> 443
@@ -134,7 +167,9 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
"${if (tls) "https" else "http"}://$displayHost:$port"
}
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
return GatewayEndpointParseResult(
config = GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl),
)
}
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
@@ -165,9 +200,44 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
internal fun resolveScannedSetupCode(rawInput: String): String? {
val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
val decoded = decodeGatewaySetupCode(setupCode) ?: return null
return setupCode.takeIf { parseGatewayEndpoint(decoded.url) != null }
return resolveScannedSetupCodeResult(rawInput).setupCode
}
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =
resolveSetupCodeCandidate(rawInput)
?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL)
val decoded =
decodeGatewaySetupCode(setupCode)
?: return GatewayScannedSetupCodeResult(error = GatewayEndpointValidationError.INVALID_URL)
val parsed = parseGatewayEndpointResult(decoded.url)
if (parsed.config == null) {
return GatewayScannedSetupCodeResult(error = parsed.error)
}
return GatewayScannedSetupCodeResult(setupCode = setupCode)
}
internal fun gatewayEndpointValidationMessage(
error: GatewayEndpointValidationError,
source: GatewayEndpointInputSource,
): String {
return when (error) {
GatewayEndpointValidationError.INSECURE_REMOTE_URL ->
when (source) {
GatewayEndpointInputSource.SETUP_CODE ->
"Setup code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix"
GatewayEndpointInputSource.QR_SCAN ->
"QR code points to an insecure remote gateway. $remoteGatewaySecurityRule $remoteGatewaySecurityFix"
GatewayEndpointInputSource.MANUAL ->
"$remoteGatewaySecurityRule $remoteGatewaySecurityFix"
}
GatewayEndpointValidationError.INVALID_URL ->
when (source) {
GatewayEndpointInputSource.SETUP_CODE -> "Setup code has invalid gateway URL."
GatewayEndpointInputSource.QR_SCAN -> "QR code did not contain a valid setup code."
GatewayEndpointInputSource.MANUAL -> "Enter a valid manual host and port to connect."
}
}
}
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {

View File

@@ -49,6 +49,7 @@ internal fun buildGatewayDiagnosticsReport(
Please:
- pick one route only: same machine, same LAN, Tailscale, or public URL
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
- remember: remote mobile nodes require wss:// or Tailscale Serve; ws:// is only for localhost or the Android emulator
- quote the exact app status/error below
- tell me whether `openclaw devices list` should show a pending pairing request
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`

View File

@@ -566,12 +566,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (contents.isEmpty()) {
return@addOnSuccessListener
}
val scannedSetupCode = resolveScannedSetupCode(contents)
if (scannedSetupCode == null) {
gatewayError = "QR code did not contain a valid setup code."
val scannedSetupCode = resolveScannedSetupCodeResult(contents)
if (scannedSetupCode.setupCode == null) {
gatewayError =
gatewayEndpointValidationMessage(
scannedSetupCode.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.QR_SCAN,
)
return@addOnSuccessListener
}
setupCode = scannedSetupCode
setupCode = scannedSetupCode.setupCode
gatewayInputMode = GatewayInputMode.SetupCode
gatewayError = null
attemptedConnect = false
@@ -799,9 +803,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
gatewayError = "Scan QR code first, or use Advanced setup."
return@Button
}
val parsedGateway = parseGatewayEndpoint(parsedSetup.url)
if (parsedGateway == null) {
gatewayError = "Setup code has invalid gateway URL."
val parsedGateway = parseGatewayEndpointResult(parsedSetup.url)
if (parsedGateway.config == null) {
gatewayError =
gatewayEndpointValidationMessage(
parsedGateway.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.SETUP_CODE,
)
return@Button
}
gatewayUrl = parsedSetup.url
@@ -819,12 +827,16 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
} else {
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
if (parsedGateway == null) {
gatewayError = "Manual endpoint is invalid."
val parsedGateway = manualUrl?.let(::parseGatewayEndpointResult)
if (parsedGateway?.config == null) {
gatewayError =
gatewayEndpointValidationMessage(
parsedGateway?.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.MANUAL,
)
return@Button
}
gatewayUrl = parsedGateway.displayUrl
gatewayUrl = parsedGateway.config.displayUrl
viewModel.setGatewayBootstrapToken("")
}
step = OnboardingStep.Permissions
@@ -863,19 +875,23 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
} else {
Button(
onClick = {
val parsed = parseGatewayEndpoint(gatewayUrl)
if (parsed == null) {
val parsed = parseGatewayEndpointResult(gatewayUrl)
if (parsed.config == null) {
step = OnboardingStep.Gateway
gatewayError = "Invalid gateway URL."
gatewayError =
gatewayEndpointValidationMessage(
parsed.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.MANUAL,
)
return@Button
}
val token = persistedGatewayToken.trim()
val password = gatewayPassword.trim()
attemptedConnect = true
viewModel.setManualEnabled(true)
viewModel.setManualHost(parsed.host)
viewModel.setManualPort(parsed.port)
viewModel.setManualTls(parsed.tls)
viewModel.setManualHost(parsed.config.host)
viewModel.setManualPort(parsed.config.port)
viewModel.setManualTls(parsed.config.tls)
if (gatewayInputMode == GatewayInputMode.Manual) {
viewModel.setGatewayBootstrapToken("")
}
@@ -886,7 +902,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
viewModel.setGatewayPassword(password)
viewModel.connect(
GatewayEndpoint.manual(host = parsed.host, port = parsed.port),
GatewayEndpoint.manual(host = parsed.config.host, port = parsed.config.port),
token = token.ifEmpty { null },
bootstrapToken =
if (gatewayInputMode == GatewayInputMode.SetupCode) {
@@ -1040,7 +1056,7 @@ private fun GatewayStep(
StepShell(title = "Gateway Connection") {
Text(
"Run `openclaw qr` on your gateway host, then scan the code with this device.",
"Run `openclaw qr` on your gateway host, then scan the code with this device. Remote mobile nodes require wss:// or Tailscale Serve.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
@@ -1072,7 +1088,7 @@ private fun GatewayStep(
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText)
Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary)
Text("Paste setup code or enter host/port manually. ws:// is only for localhost or the Android emulator.", style = onboardingCaption1Style, color = onboardingTextSecondary)
}
Icon(
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
@@ -1153,7 +1169,11 @@ private fun GatewayStep(
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
Text(
"Required for remote hosts. Use Tailscale Serve or a wss:// gateway URL.",
style = onboardingCalloutStyle.copy(lineHeight = 18.sp),
color = onboardingTextSecondary,
)
}
Switch(
checked = manualTls,

View File

@@ -2,6 +2,8 @@ package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -77,7 +79,7 @@ class GatewayBootstrapAuthTest {
NodeRuntime(
app,
prefs,
tlsFingerprintProbe = { _, _ -> "fp-1" },
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp-1") },
)
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
val explicitAuth =
@@ -98,6 +100,29 @@ class GatewayBootstrapAuthTest {
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession"))
}
@Test
fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() {
val app = RuntimeEnvironment.getApplication()
val runtime =
NodeRuntime(
app,
tlsFingerprintProbe = { _, _ ->
GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
},
)
runtime.connect(
GatewayEndpoint.manual(host = "gateway.example", port = 18789),
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
)
assertEquals(
"Failed: remote mobile nodes require wss:// or Tailscale Serve. No TLS endpoint detected for this host.",
waitForStatusText(runtime),
)
assertNull(runtime.pendingGatewayTrust.value)
}
private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt {
repeat(50) {
runtime.pendingGatewayTrust.value?.let { return it }
@@ -106,6 +131,17 @@ class GatewayBootstrapAuthTest {
error("Expected pending gateway trust prompt")
}
private fun waitForStatusText(runtime: NodeRuntime): String {
repeat(50) {
val status = runtime.statusText.value
if (status != "Verify gateway TLS fingerprint…") {
return status
}
Thread.sleep(10)
}
error("Expected status text update")
}
private fun desiredBootstrapToken(runtime: NodeRuntime, sessionFieldName: String): String? {
val session = readField<GatewaySession>(runtime, sessionFieldName)
val desired = readField<Any?>(session, "desired") ?: return null

View File

@@ -220,6 +220,25 @@ class GatewayConfigResolverTest {
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultFlagsInsecureRemoteGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
}
@Test
fun parseGatewayEndpointResultFlagsInsecureRemoteGateway() {
val parsed = parseGatewayEndpointResult("ws://gateway.example:18789")
assertNull(parsed.config)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
}
@Test
fun decodeGatewaySetupCodeParsesBootstrapToken() {
val setupCode =

View File

@@ -1,14 +1,14 @@
---
summary: "CLI reference for `openclaw qr` (generate iOS pairing QR + setup code)"
summary: "CLI reference for `openclaw qr` (generate mobile pairing QR + setup code)"
read_when:
- You want to pair the iOS app with a gateway quickly
- You want to pair a mobile node app with a gateway quickly
- You need setup-code output for remote/manual sharing
title: "qr"
---
# `openclaw qr`
Generate an iOS pairing QR and setup code from your current Gateway configuration.
Generate a mobile pairing QR and setup code from your current Gateway configuration.
## Usage
@@ -35,6 +35,7 @@ openclaw qr --url wss://gateway.example/ws
- `--token` and `--password` are mutually exclusive.
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
- Mobile pairing fails closed for insecure remote `ws://` gateway URLs. For remote/mobile use, prefer Tailscale Serve/Funnel or a `wss://` gateway URL. Plain `ws://` is only valid for localhost/debugging.
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.
- Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed:
- `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins).

View File

@@ -95,6 +95,12 @@ If the gateway can detect it is running under Tailscale, it publishes `tailnetDn
The macOS app now prefers MagicDNS names over raw Tailscale IPs for gateway discovery. This improves reliability when tailnet IPs change (for example after node restarts or CGNAT reassignment), because MagicDNS names resolve to the current IP automatically.
For mobile node pairing, discovery hints do not relax transport security:
- iOS/Android still require a secure first-time remote connect path (`wss://` or Tailscale Serve/Funnel).
- A discovered raw tailnet IP is a routing hint, not permission to use plaintext remote `ws://`.
- If you want the simplest Tailscale path for mobile nodes, use Tailscale Serve so discovery and the setup code both resolve to the same secure MagicDNS endpoint.
### 3) Manual / SSH target
When there is no direct route (or direct is disabled), clients can always connect via SSH by forwarding the loopback gateway port.
@@ -108,6 +114,7 @@ Recommended client behavior:
1. If a paired direct endpoint is configured and reachable, use it.
2. Else, if Bonjour finds a gateway on LAN, offer a one-tap “Use this gateway” choice and save it as the direct endpoint.
3. Else, if a tailnet DNS/IP is configured, try direct.
For mobile nodes, direct means a secure endpoint, not plaintext remote `ws://`.
4. Else, fall back to SSH.
## Pairing + auth (direct transport)

View File

@@ -27,7 +27,13 @@ System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gatew
Android node app ⇄ (mDNS/NSD + WebSocket) ⇄ **Gateway**
Android connects directly to the Gateway WebSocket (default `ws://<host>:18789`) and uses device pairing (`role: node`).
Android connects directly to the Gateway WebSocket and uses device pairing (`role: node`).
For remote hosts, Android requires a secure endpoint:
- Preferred: Tailscale Serve / Funnel with `https://<magicdns>` / `wss://<magicdns>`
- Also supported: any other `wss://` Gateway URL with a real TLS endpoint
- Local debugging only: `ws://` on `localhost`, `127.0.0.1`, or the Android emulator bridge (`10.0.2.2`)
### Prerequisites
@@ -36,6 +42,7 @@ Android connects directly to the Gateway WebSocket (default `ws://<host>:18789`)
- Same LAN with mDNS/NSD, **or**
- Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or**
- Manual gateway host/port (fallback)
- Remote mobile pairing does **not** use raw tailnet IP `ws://` endpoints. Use Tailscale Serve or another `wss://` URL instead.
- You can run the CLI (`openclaw`) on the gateway machine (or via SSH).
### 1) Start the Gateway
@@ -48,10 +55,13 @@ Confirm in logs you see something like:
- `listening on ws://0.0.0.0:18789`
For tailnet-only setups (recommended for Vienna ⇄ London), bind the gateway to the tailnet IP:
For remote Android access over Tailscale, prefer Serve/Funnel instead of a raw tailnet bind:
- Set `gateway.bind: "tailnet"` in `~/.openclaw/openclaw.json` on the gateway host.
- Restart the Gateway / macOS menubar app.
```bash
openclaw gateway --tailscale serve
```
This gives Android a secure `wss://` / `https://` endpoint. A plain `gateway.bind: "tailnet"` setup is not enough for first-time remote Android pairing unless you also terminate TLS separately.
### 2) Verify discovery (optional)
@@ -65,7 +75,9 @@ More debugging notes: [Bonjour](/gateway/bonjour).
#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
Android NSD/mDNS discovery wont cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
Android NSD/mDNS discovery wont cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead.
Discovery alone is not sufficient for remote Android pairing. The discovered route still needs a secure endpoint (`wss://` or Tailscale Serve):
1. Set up a DNS-SD zone (example `openclaw.internal.`) on the gateway host and publish `_openclaw-gw._tcp` records.
2. Configure Tailscale split DNS for your chosen domain pointing at that DNS server.
@@ -79,7 +91,7 @@ In the Android app:
- The app keeps its gateway connection alive via a **foreground service** (persistent notification).
- Open the **Connect** tab.
- Use **Setup Code** or **Manual** mode.
- If discovery is blocked, use manual host/port (and TLS/token/password when required) in **Advanced controls**.
- If discovery is blocked, use manual host/port in **Advanced controls**. For remote hosts, turn on TLS and use a `wss://` / Tailscale Serve endpoint.
After the first successful pairing, Android auto-reconnects on launch:

View File

@@ -201,7 +201,7 @@ const entries: SubCliEntry[] = [
},
{
name: "qr",
description: "Generate iOS pairing QR/setup code",
description: "Generate mobile pairing QR/setup code",
hasSubcommands: false,
register: async (program) => {
const mod = await import("../qr-cli.js");

View File

@@ -80,7 +80,7 @@ export const SUB_CLI_DESCRIPTORS = [
},
{
name: "qr",
description: "Generate iOS pairing QR/setup code",
description: "Generate mobile pairing QR/setup code",
hasSubcommands: false,
},
{

View File

@@ -87,7 +87,7 @@ function createLocalGatewayConfigWithAuth(auth: Record<string, unknown>) {
secrets: createDefaultSecretProvider(),
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "127.0.0.1",
auth,
},
};
@@ -149,7 +149,7 @@ describe("registerQrCli", () => {
}
function expectLoggedLocalSetupCode() {
expectLoggedSetupCode("ws://gateway.local:18789");
expectLoggedSetupCode("ws://127.0.0.1:18789");
}
function mockTailscaleStatusLookup() {
@@ -178,7 +178,7 @@ describe("registerQrCli", () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "127.0.0.1",
auth: { mode: "token", token: "tok" },
},
});
@@ -186,7 +186,7 @@ describe("registerQrCli", () => {
await runQr(["--setup-code-only"]);
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
url: "ws://127.0.0.1:18789",
bootstrapToken: "bootstrap-123",
});
expect(runtime.log).toHaveBeenCalledWith(expected);
@@ -198,7 +198,7 @@ describe("registerQrCli", () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "127.0.0.1",
auth: { mode: "token", token: "tok" },
},
});
@@ -213,11 +213,27 @@ describe("registerQrCli", () => {
expect(output).toContain("openclaw devices approve <requestId>");
});
it("accepts --token override when config has no auth", async () => {
it("fails fast for insecure remote mobile pairing setup urls", async () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: { mode: "token", token: "tok" },
},
});
await expectQrExit(["--setup-code-only"]);
const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("Mobile pairing requires a secure remote gateway URL");
expect(output).toContain("gateway.tailscale.mode=serve");
});
it("accepts --token override when config has no auth", async () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
},
});

View File

@@ -98,7 +98,7 @@ function emitQrSecretResolveDiagnostics(diagnostics: string[], opts: QrCliOption
export function registerQrCli(program: Command) {
program
.command("qr")
.description("Generate an iOS pairing QR code and setup code")
.description("Generate a mobile pairing QR code and setup code")
.addHelpText(
"after",
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/qr", "docs.openclaw.ai/cli/qr")}\n`,
@@ -236,7 +236,7 @@ export function registerQrCli(program: Command) {
const lines: string[] = [
theme.heading("Pairing QR"),
"Scan this with the OpenClaw iOS app (Onboarding -> Scan QR).",
"Scan this with the OpenClaw mobile app (Onboarding -> Scan QR).",
"",
];

View File

@@ -50,7 +50,7 @@ function createGatewayTokenRefFixture() {
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "127.0.0.1",
port: 18789,
auth: {
mode: "token",
@@ -157,7 +157,7 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
const setupCode = runtimeLogs.at(-1);
expect(setupCode).toBeTruthy();
const payload = decodeSetupCode(setupCode ?? "");
expect(payload.url).toBe("ws://gateway.local:18789");
expect(payload.url).toBe("ws://127.0.0.1:18789");
expect(payload.bootstrapToken).toBeTruthy();
expect(runtimeErrors).toEqual([]);

View File

@@ -43,7 +43,7 @@ describe("pairing setup code", () => {
...config,
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "127.0.0.1",
auth,
},
};
@@ -57,6 +57,23 @@ describe("pairing setup code", () => {
}));
}
function createIpv4NetworkInterfaces(
address: string,
): ReturnType<NonNullable<NonNullable<ResolveSetupOptions>["networkInterfaces"]>> {
return {
en0: [
{
address,
family: "IPv4",
internal: false,
netmask: "255.255.255.0",
mac: "00:00:00:00:00:00",
cidr: `${address}/24`,
},
],
};
}
function expectResolvedSetupOk(
resolved: ResolvedSetup,
params: {
@@ -279,7 +296,7 @@ describe("pairing setup code", () => {
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "127.0.0.1",
auth: { token },
},
...defaultEnvSecretProviderConfig,
@@ -351,14 +368,14 @@ describe("pairing setup code", () => {
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "127.0.0.1",
port: 19001,
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "token",
url: "ws://gateway.local:19001",
url: "ws://127.0.0.1:19001",
urlSource: "gateway.bind=custom",
},
},
@@ -367,7 +384,7 @@ describe("pairing setup code", () => {
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "127.0.0.1",
auth: { mode: "token", token: "old" },
},
} satisfies ResolveSetupConfig,
@@ -378,7 +395,7 @@ describe("pairing setup code", () => {
} satisfies ResolveSetupOptions,
expected: {
authLabel: "token",
url: "ws://gateway.local:18789",
url: "ws://127.0.0.1:18789",
urlSource: "gateway.bind=custom",
},
},
@@ -390,6 +407,52 @@ describe("pairing setup code", () => {
});
});
it.each([
{
name: "rejects custom bind remote ws setup urls for mobile pairing",
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expectedError: "Mobile pairing requires a secure remote gateway URL",
},
{
name: "rejects tailnet bind remote ws setup urls for mobile pairing",
config: {
gateway: {
bind: "tailnet",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => createIpv4NetworkInterfaces("100.64.0.9"),
} satisfies ResolveSetupOptions,
expectedError: "prefer gateway.tailscale.mode=serve",
},
{
name: "rejects lan bind remote ws setup urls for mobile pairing",
config: {
gateway: {
bind: "lan",
auth: { mode: "password", password: "secret" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => createIpv4NetworkInterfaces("192.168.1.20"),
} satisfies ResolveSetupOptions,
expectedError: "ws:// is only valid for localhost",
},
] as const)("$name", async ({ config, options, expectedError }) => {
await expectResolvedSetupFailureCase({
config,
options,
expectedError,
});
});
it.each([
{
name: "errors when gateway is loopback only",

View File

@@ -7,6 +7,7 @@ import {
resolveSecretInputRef,
} from "../config/types.secrets.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { isLoopbackHost, isSecureWebSocketUrl } from "../gateway/net.js";
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js";
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
import {
@@ -62,6 +63,34 @@ type ResolveUrlResult = {
error?: string;
};
function describeSecureMobilePairingFix(source?: string): string {
const sourceNote = source ? ` Resolved source: ${source}.` : "";
return (
"Mobile pairing requires a secure remote gateway URL (wss://) or Tailscale Serve/Funnel." +
sourceNote +
" Fix: prefer gateway.tailscale.mode=serve, or set gateway.remote.url / " +
"plugins.entries.device-pair.config.publicUrl to a wss:// URL. ws:// is only valid for localhost."
);
}
function validateMobilePairingUrl(url: string, source?: string): string | null {
if (isSecureWebSocketUrl(url)) {
return null;
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return "Resolved mobile pairing URL is invalid.";
}
const protocol =
parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol;
if (protocol !== "ws:" || isLoopbackHost(parsed.hostname)) {
return null;
}
return describeSecureMobilePairingFix(source);
}
type ResolveAuthLabelResult = {
label?: "token" | "password";
error?: string;
@@ -373,6 +402,10 @@ export async function resolvePairingSetupFromConfig(
if (!urlResult.url) {
return { ok: false, error: urlResult.error ?? "Gateway URL unavailable." };
}
const mobilePairingUrlError = validateMobilePairingUrl(urlResult.url, urlResult.source);
if (mobilePairingUrlError) {
return { ok: false, error: mobilePairingUrlError };
}
if (!authLabel.label) {
return { ok: false, error: "Gateway auth is not configured (no token or password)." };