feat(pairing): one-paste device pairing via oc-pair setup links (#120768)

* feat(pairing): one-paste device pairing via oc-pair setup links

Implements milestone 3 from docs/plan/runners.md.

* fix(pairing): sign bootstrap handshake, keep URL candidates, wire pairing countdown

* test(gateway): update client callsite guard

* fix(pairing): preserve setup URL context paths

* fix(ui): keep pairing help aligned with setup mode

* fix(pairing): isolate bootstrap credentials

* perf(ui): keep one-paste pairing within bundle budget

* refactor(pairing): isolate native pair URL prefix parsing

* fix(pairing): preserve candidate lifecycle state

* fix(pairing): retire shared credentials after bootstrap

* fix(pairing): apply rotated manifest through client owner

* test(pairing): prove bootstrap retirement across reconnect

* fix(pairing): preserve native gateway context paths

* fix(pairing): carry native context paths through reconnect

* fix(ios): preserve encoded gateway context path

* chore(plugin-sdk): refresh pairing API baselines
This commit is contained in:
Peter Steinberger
2026-08-12 02:01:33 -07:00
committed by GitHub
parent 3b01ea7905
commit d44f70eb4b
92 changed files with 2432 additions and 545 deletions
File diff suppressed because it is too large Load Diff
@@ -842,6 +842,7 @@ class MainViewModel private constructor(
host = config.host,
port = config.port,
tlsEnabled = config.tls,
contextPath = config.contextPath,
)
val targetAlreadyPaired =
prefs.gatewayRegistry.entries.value
@@ -876,6 +877,7 @@ class MainViewModel private constructor(
host = config.host,
port = config.port,
tls = config.tls,
contextPath = config.contextPath,
),
)
@@ -8530,6 +8530,7 @@ internal fun manualGatewayEndpoint(entry: GatewayRegistryEntry): GatewayEndpoint
host = normalizedHost,
port = normalizedPort,
tlsEnabled = entry.tls,
contextPath = entry.contextPath,
)
}
@@ -8545,6 +8546,7 @@ internal fun gatewayRegistryEntry(
host = endpoint.host,
port = endpoint.port,
tls = endpoint.tlsEnabled,
contextPath = endpoint.contextPath,
lastConnectedAtMs = existing?.lastConnectedAtMs ?: 0L,
)
} else {
@@ -12,6 +12,7 @@ data class GatewayEndpoint(
val canvasPort: Int? = null,
val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null,
val contextPath: String = "",
) {
companion object {
/** Builds a stable manual endpoint key that survives display-name changes. */
@@ -19,14 +20,65 @@ data class GatewayEndpoint(
host: String,
port: Int,
tlsEnabled: Boolean = false,
): GatewayEndpoint =
GatewayEndpoint(
stableId = "manual|${host.lowercase()}|$port",
contextPath: String = "",
): GatewayEndpoint {
val normalizedContextPath = normalizeGatewayContextPath(contextPath)
val stableIdPath = if (normalizedContextPath.isEmpty()) "" else "|$normalizedContextPath"
return GatewayEndpoint(
stableId = "manual|${host.lowercase()}|$port$stableIdPath",
name = "$host:$port",
host = host,
port = port,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = null,
contextPath = normalizedContextPath,
)
}
}
}
internal fun normalizeGatewayContextPath(value: String?): String {
val path = value.orEmpty()
if (path.isEmpty() || path == "/") return ""
val prefixed = if (path.startsWith('/')) path else "/$path"
val encoded = StringBuilder(prefixed.length)
var index = 0
while (index < prefixed.length) {
if (
prefixed[index] == '%' &&
index + 2 < prefixed.length &&
prefixed[index + 1].isAsciiHexDigit() &&
prefixed[index + 2].isAsciiHexDigit()
) {
encoded.append(prefixed, index, index + 3)
index += 3
continue
}
val codePoint = prefixed.codePointAt(index)
if (isGatewayPathCodePoint(codePoint)) {
encoded.appendCodePoint(codePoint)
} else {
for (byte in String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)) {
val value = byte.toInt() and 0xff
encoded.append('%')
encoded.append(HEX_DIGITS[value ushr 4])
encoded.append(HEX_DIGITS[value and 0x0f])
}
}
index += Character.charCount(codePoint)
}
return encoded.toString()
}
private const val HEX_DIGITS = "0123456789ABCDEF"
private fun Char.isAsciiHexDigit(): Boolean = this in '0'..'9' || this in 'A'..'F' || this in 'a'..'f'
private fun isGatewayPathCodePoint(value: Int): Boolean =
value == '/'.code ||
value == ':'.code ||
value == '@'.code ||
value in 'A'.code..'Z'.code ||
value in 'a'.code..'z'.code ||
value in '0'.code..'9'.code ||
(value <= 0x7f && value.toChar() in "-._~!$&'()*+,;=")
@@ -28,6 +28,7 @@ data class GatewayRegistryEntry(
val port: Int? = null,
val tls: Boolean = true,
val lastConnectedAtMs: Long = 0L,
val contextPath: String = "",
)
@Serializable
@@ -83,6 +84,7 @@ class GatewayRegistryStore(
stableId = stableId,
name = entry.name.trim().ifEmpty { stableId },
host = entry.host?.trim()?.takeIf { it.isNotEmpty() },
contextPath = normalizeGatewayContextPath(entry.contextPath),
lastConnectedAtMs =
if (entry.lastConnectedAtMs == 0L) {
existing?.lastConnectedAtMs ?: 0L
@@ -2175,9 +2175,11 @@ internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,
useTls: Boolean,
contextPath: String = "",
): String {
val scheme = if (useTls) "wss" else "ws"
return "$scheme://${formatGatewayAuthority(host, port)}"
val path = normalizeGatewayContextPath(contextPath)
return "$scheme://${formatGatewayAuthority(host, port)}$path"
}
/** Builds one gateway upgrade request without exposing proxy credentials to cleartext routes. */
@@ -2186,7 +2188,15 @@ internal fun buildGatewayWebSocketUpgradeRequest(
tls: GatewayTlsParams?,
customHeadersProvider: ((stableId: String) -> Map<String, String>)?,
): Request {
val request = Request.Builder().url(buildGatewayWebSocketUrl(endpoint.host, endpoint.port, tls != null))
val request =
Request.Builder().url(
buildGatewayWebSocketUrl(
endpoint.host,
endpoint.port,
tls != null,
endpoint.contextPath,
),
)
if (tls == null) return request.build()
// Read at connect time so edits apply on the next reconnect. Headers may contain service tokens
@@ -1,6 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
import ai.openclaw.app.gateway.normalizeGatewayContextPath
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
@@ -20,6 +21,7 @@ internal data class GatewayEndpointConfig(
val port: Int,
val tls: Boolean,
val displayUrl: String,
val contextPath: String = "",
)
/** Effective transport shown by manual gateway forms before they connect. */
@@ -45,6 +47,7 @@ internal data class GatewayConnectConfig(
val bootstrapToken: String,
val token: String,
val password: String,
val contextPath: String = "",
)
/** How a connection attempt may update credentials already owned by the runtime. */
@@ -136,6 +139,7 @@ internal fun resolveGatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
contextPath = parsed.contextPath,
bootstrapToken = setupBootstrapToken,
token = sharedToken,
password = sharedPassword,
@@ -151,6 +155,7 @@ internal fun resolveGatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
contextPath = parsed.contextPath,
bootstrapToken = bootstrapToken,
token = token,
password = password,
@@ -206,7 +211,11 @@ internal fun resolveGatewayConnectPlan(
return GatewayConnectPlan(config, action)
}
private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean = host.equals(config.host, ignoreCase = true) && port == config.port && tls == config.tls
private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean =
host.equals(config.host, ignoreCase = true) &&
port == config.port &&
tls == config.tls &&
contextPath == config.contextPath
/** Parses an endpoint string and returns only the valid connection config. */
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config
@@ -221,6 +230,9 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
runCatching { URI(normalized) }
.getOrNull()
?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
}
val host =
uri.host
?.trim()
@@ -247,22 +259,31 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
val defaultPort = if (tls) 443 else 18789
val displayPort = if (tls) 443 else 80
val port = gatewayPort(uri.port, defaultPort) ?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val contextPath = normalizeGatewayContextPath(uri.rawPath)
val displayPath = contextPath
val displayHost = if (host.contains(":")) "[$host]" else host
val displayUrl =
if (port == displayPort && defaultPort == displayPort) {
"${if (tls) "https" else "http"}://$displayHost"
"${if (tls) "https" else "http"}://$displayHost$displayPath"
} else {
"${if (tls) "https" else "http"}://$displayHost:$port"
"${if (tls) "https" else "http"}://$displayHost:$port$displayPath"
}
return GatewayEndpointParseResult(
config = GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl),
config =
GatewayEndpointConfig(
host = host,
port = port,
tls = tls,
displayUrl = displayUrl,
contextPath = contextPath,
),
)
}
/** Decodes base64url setup-code payloads produced by gateway onboarding. */
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val trimmed = rawInput.trim()
val trimmed = stripPairingSetupUrlPrefix(rawInput.trim())
if (trimmed.isEmpty()) return null
val padded =
@@ -512,3 +533,12 @@ private fun jsonField(
val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
return value.ifEmpty { null }
}
private const val PAIRING_SETUP_URL_PREFIX = "oc-pair://"
private fun stripPairingSetupUrlPrefix(raw: String): String =
if (raw.startsWith(PAIRING_SETUP_URL_PREFIX, ignoreCase = true)) {
raw.substring(PAIRING_SETUP_URL_PREFIX.length)
} else {
raw
}
@@ -72,6 +72,38 @@ class GatewayRegistryStoreTest {
assertEquals(first, second)
}
@Test
fun roundTripPreservesManualGatewayContextPath() {
val (prefs, securePrefs) = freshPrefs()
val endpoint =
GatewayEndpoint.manual(
host = "gateway.example",
port = 443,
tlsEnabled = true,
contextPath = "/openclaw-gw",
)
prefs.gatewayRegistry.upsert(
GatewayRegistryEntry(
stableId = endpoint.stableId,
kind = GatewayRegistryEntryKind.MANUAL,
name = endpoint.name,
host = endpoint.host,
port = endpoint.port,
tls = endpoint.tlsEnabled,
contextPath = endpoint.contextPath,
),
)
val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
assertEquals(
"/openclaw-gw",
restored.entries.value
.single()
.contextPath,
)
}
@Test
fun failedRemovalCommitDoesNotPublishCandidateState() {
val (_, securePrefs) = freshPrefs()
@@ -21,6 +21,37 @@ class GatewaySessionInvokeTimeoutTest {
assertEquals("wss://[::1]:443", buildGatewayWebSocketUrl("[::1]", 443, useTls = true))
}
@Test
fun buildGatewayWebSocketUrl_preservesAndEncodesContextPath() {
assertEquals(
"wss://gateway.example:443/openclaw%20gateway",
buildGatewayWebSocketUrl(
host = "gateway.example",
port = 443,
useTls = true,
contextPath = "/openclaw%20gateway",
),
)
assertEquals(
"wss://gateway.example:443/openclaw%2Fgateway",
buildGatewayWebSocketUrl(
host = "gateway.example",
port = 443,
useTls = true,
contextPath = "/openclaw%2Fgateway",
),
)
assertEquals(
"wss://gateway.example:443//openclaw",
buildGatewayWebSocketUrl(
host = "gateway.example",
port = 443,
useTls = true,
contextPath = "//openclaw",
),
)
}
@Test
fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() {
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null))
@@ -109,6 +109,30 @@ class GatewayConfigResolverTest {
)
}
@Test
fun parseGatewayEndpointPreservesDecodedContextPath() {
val parsed = parseGatewayEndpoint("wss://gateway.example/openclaw%20gateway")
assertEquals("/openclaw%20gateway", parsed?.contextPath)
assertEquals("https://gateway.example/openclaw%20gateway", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointPreservesEscapedPathDelimiter() {
val parsed = parseGatewayEndpoint("wss://gateway.example/openclaw%2Fgateway")
assertEquals("/openclaw%2Fgateway", parsed?.contextPath)
assertEquals("https://gateway.example/openclaw%2Fgateway", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointPreservesRepeatedLeadingPathSlashes() {
val parsed = parseGatewayEndpoint("wss://gateway.example//openclaw")
assertEquals("//openclaw", parsed?.contextPath)
assertEquals("https://gateway.example//openclaw", parsed?.displayUrl)
}
@Test
fun parseGatewayEndpointRejectsNonLoopbackCleartextWsUrls() {
assertEndpointRejected("ws://gateway.example")
@@ -375,6 +399,22 @@ class GatewayConfigResolverTest {
assertEquals(GatewayEndpointValidationError.INVALID_URL, parsed.error)
}
@Test
fun parseGatewayEndpointResultRejectsCredentialsQueriesAndFragments() {
val urls =
listOf(
"wss://user@gateway.example/openclaw-gw",
"wss://gateway.example/openclaw-gw?mode=setup",
"wss://gateway.example/openclaw-gw#fragment",
)
for (url in urls) {
val parsed = parseGatewayEndpointResult(url)
assertNull(url, parsed.config)
assertEquals(url, GatewayEndpointValidationError.INVALID_URL, parsed.error)
}
}
@Test
fun parseGatewayEndpointResultAllowsPrivateLanCleartextGateway() {
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
@@ -420,6 +460,17 @@ class GatewayConfigResolverTest {
assertNull(decoded?.password)
}
@Test
fun decodeGatewaySetupCodeAcceptsPairingUrlWrapper() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"Bootstrap-AbC123"}""")
val decoded = decodeGatewaySetupCode("oc-pair://$setupCode")
assertEquals("wss://gateway.example:18789", decoded?.url)
assertEquals("Bootstrap-AbC123", decoded?.bootstrapToken)
}
@Test
fun manualTokenDetectsSetupCodePayloads() {
val setupCode =
@@ -450,6 +501,20 @@ class GatewayConfigResolverTest {
assertEquals("", resolved?.password)
}
@Test
fun resolveGatewayConnectConfigPreservesSetupContextPath() {
val resolved =
resolveConnectConfigFixture(
useSetupCode = true,
setupCode = setupCode("wss://gateway.example/openclaw-gw"),
)
assertEquals("gateway.example", resolved?.host)
assertEquals(443, resolved?.port)
assertEquals(true, resolved?.tls)
assertEquals("/openclaw-gw", resolved?.contextPath)
}
@Test
fun resolveGatewayConnectConfigAcceptsQrJsonSetupCodePayload() {
val setupCode = setupCode("wss://gateway.example:18789")
@@ -630,9 +695,9 @@ class GatewayConfigResolverTest {
val cases =
listOf(
"ws://gateway.local:18790" to true,
"http://192.168.1.20:18790/gateway?mode=manual" to true,
"http://192.168.1.20:18790/gateway" to true,
"wss://gateway.example:8443" to false,
"https://gateway.example/gateway?mode=manual" to false,
"https://gateway.example/gateway" to false,
"HTTPS://gateway.example:443" to false,
"WS://GATEWAY.LOCAL.:18790" to true,
"ws://[::1]:18790" to true,
@@ -763,6 +828,9 @@ class GatewayConfigResolverTest {
"gateway.local:18789#evil.example",
"[::1]:18789?redirect=evil.example",
"[::1]:18789#evil.example",
"wss://user@gateway.example/openclaw-gw",
"wss://gateway.example/openclaw-gw?mode=manual",
"wss://gateway.example/openclaw-gw#fragment",
)
for (hostInput in hosts) {
@@ -56,6 +56,7 @@ struct SettingsProTab: View {
@State var gatewayPassword = ""
@State var gatewayCredentialFieldStableID: String?
@State var manualGatewayPortText = ""
@State var manualGatewayContextPath: String?
@State var setupStatusText: String?
@State var setupAttemptID: UUID?
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
@@ -214,6 +214,15 @@ extension SettingsProTab {
func syncSettingsState() {
self.refreshGatewayRegistry()
self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : ""
let activeManual = GatewaySettingsStore.activeGatewayEntry()
if activeManual?.kind == .manual,
activeManual?.host?.caseInsensitiveCompare(self.manualGatewayHost) == .orderedSame,
activeManual?.port == self.manualGatewayPort
{
self.manualGatewayContextPath = activeManual?.contextPath
} else {
self.manualGatewayContextPath = nil
}
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
self.refreshLocationPermissionSummary()
@@ -371,6 +380,7 @@ extension SettingsProTab {
self.manualGatewayPort = link.port
self.manualGatewayPortText = String(link.port)
self.manualGatewayTLS = link.tls
self.manualGatewayContextPath = link.contextPath
let instanceId = GatewaySettingsStore.currentInstanceID()
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
@@ -543,6 +553,7 @@ extension SettingsProTab {
host: host,
port: port,
useTLS: self.manualGatewayTLS,
contextPath: self.manualGatewayContextPath,
authOverride: authOverride)
// The controller now owns this attempt's immutable override. A later retry must reload
// durable state so a spent bootstrap token cannot be resurrected from the live view.
@@ -830,7 +841,8 @@ extension SettingsProTab {
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
return GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
port: port,
contextPath: self.manualGatewayContextPath)
}
var gatewayCredentialTargetStableID: String? {
@@ -879,6 +891,7 @@ extension SettingsProTab {
get: { self.manualGatewayHost },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualGatewayContextPath = nil
self.manualGatewayHost = value
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
@@ -968,6 +981,7 @@ extension SettingsProTab {
get: { self.manualGatewayPortText },
set: { newValue in
let previousStableID = self.currentManualGatewayStableID
self.manualGatewayContextPath = nil
let filtered = newValue.filter(\.isNumber)
self.manualGatewayPortText = filtered
self.manualGatewayPort = Int(filtered) ?? 0
@@ -1334,6 +1334,7 @@ extension SettingsProTab {
get: { self.manualGatewayTransport.effectiveTLS },
set: { enabled in
guard !self.manualGatewayTransport.requiresTLS else { return }
self.manualGatewayContextPath = nil
self.manualGatewayTLS = enabled
})
}
@@ -16,13 +16,17 @@ struct GatewayManualTransportPresentation: Equatable {
}
extension GatewayConnectionController {
func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
let scheme = useTLS ? "wss" : "ws"
var components = URLComponents()
components.scheme = scheme
components.host = host
components.port = port
return components.url
func buildGatewayURL(
host: String,
port: Int,
useTLS: Bool,
contextPath: String? = nil) -> URL?
{
GatewayConnectEndpoint(
host: host,
port: port,
tls: useTLS,
contextPath: contextPath).websocketURL
}
func resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {
@@ -51,8 +55,8 @@ extension GatewayConnectionController {
helperText: helperText)
}
func manualStableID(host: String, port: Int) -> String {
ManualAuthOverride.manualStableID(host: host, port: port)
func manualStableID(host: String, port: Int, contextPath: String? = nil) -> String {
ManualAuthOverride.manualStableID(host: host, port: port, contextPath: contextPath)
}
func makeConnectOptions(
@@ -189,8 +189,14 @@ extension GatewayConnectionController {
suppressStoredDeviceAuth: pendingOverride.suppressStoredDeviceAuth)
}
static func manualStableID(host: String, port: Int) -> String {
"manual|\(host.lowercased())|\(port)"
static func manualStableID(host: String, port: Int, contextPath: String? = nil) -> String {
let endpoint = GatewayConnectEndpoint(
host: host,
port: port,
tls: true,
contextPath: contextPath)
let pathSuffix = endpoint.contextPath.map { "|\($0)" } ?? ""
return "manual|\(host.lowercased())|\(port)\(pathSuffix)"
}
static func setupAuth(from link: GatewayConnectDeepLink) -> SetupAuth {
@@ -198,7 +204,10 @@ extension GatewayConnectionController {
token: link.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
bootstrapToken: link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
password: link.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
targetStableID: self.manualStableID(host: link.host, port: link.port))
targetStableID: self.manualStableID(
host: link.host,
port: link.port,
contextPath: link.contextPath))
}
}
}
@@ -390,6 +390,7 @@ final class GatewayConnectionController {
host: String,
port: Int,
useTLS: Bool,
contextPath: String? = nil,
authOverride: ManualAuthOverride? = nil,
forceReconnect: Bool = false) async
{
@@ -399,7 +400,10 @@ final class GatewayConnectionController {
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
guard let resolvedPort = Self.resolvedManualPort(host: host, port: port)
else { return }
let stableID = self.manualStableID(host: host, port: resolvedPort)
let stableID = self.manualStableID(
host: host,
port: resolvedPort,
contextPath: contextPath)
self.pendingConnectionStableID = stableID
await self.waitForPendingForgetCleanup(stableID: stableID)
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
@@ -422,7 +426,12 @@ final class GatewayConnectionController {
: nil)
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if resolvedUseTLS, stored == nil {
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
guard let url = self.buildGatewayURL(
host: host,
port: resolvedPort,
useTLS: true,
contextPath: contextPath)
else { return }
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
guard let probeResult = await self.probeTLSFingerprint(
host: host,
@@ -465,7 +474,8 @@ final class GatewayConnectionController {
guard let url = self.buildGatewayURL(
host: host,
port: resolvedPort,
useTLS: tlsParams?.required == true)
useTLS: tlsParams?.required == true,
contextPath: contextPath)
else { return }
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
stableID: stableID,
@@ -474,6 +484,7 @@ final class GatewayConnectionController {
host: host,
port: resolvedPort,
useTLS: resolvedUseTLS && tlsParams != nil,
contextPath: contextPath,
lastConnectedAtMs: nil)
guard self.persistActiveGateway(registryEntry) else { return }
self.didAutoConnect = true
@@ -496,7 +507,12 @@ final class GatewayConnectionController {
switch active.kind {
case .manual:
guard let host = active.host, let port = active.port else { return }
await self.connectManual(host: host, port: port, useTLS: active.useTLS, forceReconnect: true)
await self.connectManual(
host: host,
port: port,
useTLS: active.useTLS,
contextPath: active.contextPath,
forceReconnect: true)
case .discovered:
if let gateway = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, active.stableID)
@@ -506,7 +522,12 @@ final class GatewayConnectionController {
}
guard let fallback = self.mostRecentlyConnectedManualGateway() else { return }
guard let host = fallback.host, let port = fallback.port else { return }
await self.connectManual(host: host, port: port, useTLS: fallback.useTLS, forceReconnect: true)
await self.connectManual(
host: host,
port: port,
useTLS: fallback.useTLS,
contextPath: fallback.contextPath,
forceReconnect: true)
}
}
@@ -533,6 +554,7 @@ final class GatewayConnectionController {
host: host,
port: port,
useTLS: entry.useTLS,
contextPath: entry.contextPath,
forceReconnect: true)
return nil
case .discovered:
@@ -815,6 +837,9 @@ final class GatewayConnectionController {
host: pending.isManual ? prompt.host : nil,
port: pending.isManual ? prompt.port : nil,
useTLS: true,
contextPath: pending.isManual
? URLComponents(url: pending.url, resolvingAgainstBaseURL: false)?.percentEncodedPath
: nil,
lastConnectedAtMs: nil)
guard self.persistActiveGateway(registryEntry) else {
_ = GatewayTLSStore.clearFingerprint(stableID: pending.stableID)
@@ -1056,7 +1081,8 @@ extension GatewayConnectionController {
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tlsParams?.required == true)
useTLS: tlsParams?.required == true,
contextPath: active.contextPath)
else { return false }
let credentials = GatewaySettingsStore.loadGatewayCredentials(
@@ -1261,7 +1287,8 @@ extension GatewayConnectionController {
let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: tls?.required == true)
useTLS: tls?.required == true,
contextPath: entry.contextPath)
else { return nil }
route = (url, tls)
case .discovered:
@@ -70,8 +70,29 @@ enum GatewaySettingsStore {
var host: String?
var port: Int?
var useTLS: Bool
var contextPath: String?
var lastConnectedAtMs: Int?
init(
stableID: String,
kind: Kind,
name: String,
host: String?,
port: Int?,
useTLS: Bool,
contextPath: String? = nil,
lastConnectedAtMs: Int?)
{
self.stableID = stableID
self.kind = kind
self.name = name
self.host = host
self.port = port
self.useTLS = useTLS
self.contextPath = contextPath
self.lastConnectedAtMs = lastConnectedAtMs
}
var id: GatewayStableIdentifier.Key {
GatewayStableIdentifier.Key(self.stableID)
}
@@ -83,6 +104,7 @@ enum GatewaySettingsStore {
lhs.host == rhs.host &&
lhs.port == rhs.port &&
lhs.useTLS == rhs.useTLS &&
lhs.contextPath == rhs.contextPath &&
lhs.lastConnectedAtMs == rhs.lastConnectedAtMs
}
}
@@ -628,6 +650,11 @@ enum GatewaySettingsStore {
if entry.kind == .manual {
let host = entry.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty, let port = entry.port, (1...65535).contains(port) else { return nil }
let contextPath = GatewayConnectEndpoint(
host: host,
port: port,
tls: entry.useTLS,
contextPath: entry.contextPath).contextPath
return GatewayRegistryEntry(
stableID: stableID,
kind: .manual,
@@ -635,6 +662,7 @@ enum GatewaySettingsStore {
host: host,
port: port,
useTLS: entry.useTLS,
contextPath: contextPath,
lastConnectedAtMs: entry.lastConnectedAtMs)
}
return GatewayRegistryEntry(
@@ -27,6 +27,7 @@ struct OnboardingWizardView: View {
@State private var manualPort: Int = 18789
@State private var manualPortText: String = "18789"
@State private var manualTLS: Bool = true
@State private var manualContextPath: String?
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var gatewayCredentialFieldStableID: String?
@@ -743,6 +744,7 @@ extension OnboardingWizardView {
get: { self.manualTransport.effectiveTLS },
set: { enabled in
guard !self.manualTransport.requiresTLS else { return }
self.manualContextPath = nil
self.manualTLS = enabled
})
}
@@ -979,6 +981,7 @@ extension OnboardingWizardView {
self.manualPort = link.port
self.manualPortText = String(link.port)
self.manualTLS = link.tls
self.manualContextPath = link.contextPath
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
if setupAuth.hasBootstrapToken {
@@ -1221,6 +1224,7 @@ extension OnboardingWizardView {
self.manualHost = host
self.manualPort = port
self.manualTLS = active.useTLS
self.manualContextPath = active.contextPath
} else {
self.manualHost = "openclaw.local"
self.manualPort = 18789
@@ -1280,7 +1284,8 @@ extension OnboardingWizardView {
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
return GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
port: port,
contextPath: self.manualContextPath)
}
private var gatewayCredentialTargetStableID: String? {
@@ -1313,6 +1318,7 @@ extension OnboardingWizardView {
get: { self.manualHost },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualContextPath = nil
self.manualHost = value
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
@@ -1327,6 +1333,7 @@ extension OnboardingWizardView {
get: { self.manualPortText },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualContextPath = nil
let digits = value.filter(\.isNumber)
self.manualPortText = digits
self.manualPort = min(Int(digits) ?? 0, 65535)
@@ -1420,6 +1427,7 @@ extension OnboardingWizardView {
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
let previousStableID = self.currentManualGatewayStableID
self.manualContextPath = nil
defer {
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
@@ -1502,6 +1510,7 @@ extension OnboardingWizardView {
host: host,
port: port,
useTLS: self.manualTLS,
contextPath: self.manualContextPath,
authOverride: authOverride,
forceReconnect: forceReconnect)
// The controller now owns this attempt's immutable override. A later retry must reload
@@ -7,6 +7,10 @@ import UIKit
@testable import OpenClaw
@testable import OpenClawKit
private func percentEncodedPath(of url: URL?) -> String? {
url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.percentEncodedPath }
}
@discardableResult
private func saveActiveManualGateway(
host: String,
@@ -924,6 +928,46 @@ private func waitUntil(
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == setupAuth.targetStableID)
}
@Test @MainActor func `setup context path survives registry reconnect`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let instanceID = "ios-context-path-\(UUID().uuidString)"
let temporaryState = try TemporaryOpenClawState(instanceID: instanceID)
defer { temporaryState.restore() }
let link = GatewayConnectDeepLink(
host: "192.168.1.41",
port: 18789,
tls: false,
contextPath: "/openclaw%2Fgateway",
bootstrapToken: nil,
token: nil,
password: nil)
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
await controller.connectManual(
host: link.host,
port: link.port,
useTLS: link.tls,
contextPath: link.contextPath,
authOverride: setupAuth.manualAuthOverride)
await waitUntil { appModel.activeGatewayConnectConfig != nil }
#expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == "/openclaw%2Fgateway")
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == setupAuth.targetStableID)
let stored = try #require(GatewaySettingsStore.activeGatewayEntry())
#expect(stored.contextPath == "/openclaw%2Fgateway")
appModel.disconnectGateway()
await controller.connectActiveGateway()
await waitUntil { appModel.activeGatewayConnectConfig != nil }
#expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == "/openclaw%2Fgateway")
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stored.stableID)
}
@Test @MainActor func `legacy auth preserves proven relay credentials and otherwise requires full re-pair`() throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
@@ -2178,6 +2222,35 @@ private func waitUntil(
#expect(!GatewaySettingsStore.loadGatewayRegistry().entries.contains { $0.stableID == stableID })
}
@Test @MainActor func `manual trust handoff persists its context path`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let host = "context-path-trust.example.com"
let contextPath = "/openclaw-gateway"
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: 443,
contextPath: contextPath)
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.clearFingerprint(stableID: stableID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = makeTLSProbeController(appModel: appModel, fingerprint: "context-path-fingerprint")
await controller.connectManual(
host: host,
port: 443,
useTLS: true,
contextPath: contextPath)
#expect(controller.pendingTrustPrompt?.stableID == stableID)
await controller.acceptPendingTrustPrompt()
await waitUntil { appModel.activeGatewayConnectConfig != nil }
#expect(percentEncodedPath(of: appModel.activeGatewayConnectConfig?.url) == contextPath)
let stored = try #require(GatewaySettingsStore.activeGatewayEntry())
#expect(stored.contextPath == contextPath)
}
@Test @MainActor func `forget gateway preserves another gateway pending trust handoff`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
@@ -695,6 +695,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
host: "z.example.com",
port: 443,
useTLS: true,
contextPath: "/openclaw-gateway",
lastConnectedAtMs: nil)
let gatewayA = GatewaySettingsStore.GatewayRegistryEntry(
stableID: "bonjour|alpha",
@@ -716,6 +717,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
#expect(registry.connectedStableIDs == [gatewayB.stableID])
#expect(GatewaySettingsStore.connectedGatewayEntries().map(\.stableID) == [gatewayB.stableID])
#expect(registry.entries.last?.lastConnectedAtMs == 1234)
#expect(registry.entries.last?.contextPath == "/openclaw-gateway")
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(gatewayA))
#expect(KeychainStore.loadString(service: gatewayService, account: "gateway-registry") == firstJSON)
@@ -4,6 +4,55 @@ private func defaultGatewayPort(tls: Bool) -> Int {
tls ? 443 : 18789
}
private func normalizeGatewayContextPath(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
let path = value.hasPrefix("/") ? value : "/\(value)"
guard path != "/" else { return nil }
// Keep valid escapes such as %2F and %FF intact because decoding them can
// change segment boundaries or reject valid non-UTF-8 path octets.
let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "%?#"))
var encoded = ""
var index = path.startIndex
while index < path.endIndex {
if path[index] == "%" {
let first = path.index(after: index)
if first < path.endIndex {
let second = path.index(after: first)
if second < path.endIndex,
path[first].isHexDigit,
path[second].isHexDigit
{
let end = path.index(after: second)
encoded.append(contentsOf: path[index..<end])
index = end
continue
}
}
encoded.append("%25")
index = path.index(after: index)
continue
}
let nextPercent = path[index...].firstIndex(of: "%") ?? path.endIndex
guard let segment = String(path[index..<nextPercent])
.addingPercentEncoding(withAllowedCharacters: allowed)
else { return nil }
encoded.append(segment)
index = nextPercent
}
return encoded
}
extension Character {
fileprivate var isHexDigit: Bool {
guard self.unicodeScalars.count == 1, let value = self.unicodeScalars.first?.value else {
return false
}
return (48...57).contains(value) ||
(65...70).contains(value) ||
(97...102).contains(value)
}
}
public enum DeepLinkRoute: Sendable, Equatable {
case agent(AgentDeepLink)
case gateway(GatewayConnectDeepLink)
@@ -12,11 +61,13 @@ public enum DeepLinkRoute: Sendable, Equatable {
public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
private static let maximumSetupEndpoints = 8
private static let pairingSetupURLPrefix = "oc-pair://"
private enum CodingKeys: String, CodingKey {
case host
case port
case tls
case contextPath
case bootstrapToken
case token
case password
@@ -37,6 +88,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let host: String
public let port: Int
public let tls: Bool
public let contextPath: String?
public let bootstrapToken: String?
public let token: String?
public let password: String?
@@ -46,6 +98,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: String,
port: Int,
tls: Bool,
contextPath: String? = nil,
bootstrapToken: String?,
token: String?,
password: String?,
@@ -54,6 +107,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
self.host = host
self.port = port
self.tls = tls
self.contextPath = normalizeGatewayContextPath(contextPath)
self.bootstrapToken = bootstrapToken
self.token = token
self.password = password
@@ -65,6 +119,8 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
self.host = try container.decode(String.self, forKey: .host)
self.port = try container.decode(Int.self, forKey: .port)
self.tls = try container.decode(Bool.self, forKey: .tls)
self.contextPath = try normalizeGatewayContextPath(
container.decodeIfPresent(String.self, forKey: .contextPath))
self.bootstrapToken = try container.decodeIfPresent(String.self, forKey: .bootstrapToken)
self.token = try container.decodeIfPresent(String.self, forKey: .token)
self.password = try container.decodeIfPresent(String.self, forKey: .password)
@@ -74,12 +130,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
public var websocketURL: URL? {
guard (1...65535).contains(self.port) else { return nil }
var components = URLComponents()
components.scheme = self.tls ? "wss" : "ws"
components.host = self.host
components.port = self.port
return components.url
self.connectionEndpoints.first?.websocketURL
}
public var isValidEndpoint: Bool {
@@ -88,7 +139,8 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
public var connectionEndpoints: [GatewayConnectEndpoint] {
[.init(host: self.host, port: self.port, tls: self.tls)] + self.fallbackEndpoints
[.init(host: self.host, port: self.port, tls: self.tls, contextPath: self.contextPath)] +
self.fallbackEndpoints
}
public func selectingEndpoint(_ endpoint: GatewayConnectEndpoint) -> GatewayConnectDeepLink {
@@ -96,6 +148,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: endpoint.host,
port: endpoint.port,
tls: endpoint.tls,
contextPath: endpoint.contextPath,
bootstrapToken: self.bootstrapToken,
token: self.token,
password: self.password)
@@ -144,8 +197,14 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
/// and `tls`. In both cases, the optional `bootstrapToken`, `token`, and `password` fields
/// are also supported.
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
var trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.range(
of: self.pairingSetupURLPrefix,
options: [.anchored, .caseInsensitive]) != nil
{
trimmed = String(trimmed.dropFirst(self.pairingSetupURLPrefix.count))
}
if let link = decodeSetupPayload(from: Data(trimmed.utf8)) {
return link
}
@@ -185,12 +244,17 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
if let primary = links.first {
let fallbacks = links.dropFirst().map {
GatewayConnectEndpoint(host: $0.host, port: $0.port, tls: $0.tls)
GatewayConnectEndpoint(
host: $0.host,
port: $0.port,
tls: $0.tls,
contextPath: $0.contextPath)
}
return GatewayConnectDeepLink(
host: primary.host,
port: primary.port,
tls: primary.tls,
contextPath: primary.contextPath,
bootstrapToken: primary.bootstrapToken,
token: primary.token,
password: primary.password,
@@ -221,7 +285,11 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
password: String?) -> GatewayConnectDeepLink?
{
guard let parsed = URLComponents(string: urlString),
let hostname = parsed.host, !hostname.isEmpty
let hostname = parsed.host, !hostname.isEmpty,
parsed.user == nil,
parsed.password == nil,
parsed.query == nil,
parsed.fragment == nil
else { return nil }
let scheme = (parsed.scheme ?? "ws").lowercased()
@@ -236,6 +304,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: hostname,
port: parsed.port ?? defaultGatewayPort(tls: tls),
tls: tls,
contextPath: parsed.percentEncodedPath,
bootstrapToken: bootstrapToken,
token: token,
password: password)
@@ -245,6 +314,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: String,
port: Int,
tls: Bool,
contextPath: String? = nil,
bootstrapToken: String?,
token: String?,
password: String?) -> GatewayConnectDeepLink?
@@ -253,6 +323,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
host: host,
port: port,
tls: tls,
contextPath: contextPath,
bootstrapToken: bootstrapToken,
token: token,
password: password)
@@ -285,14 +356,42 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
public struct GatewayConnectEndpoint: Codable, Sendable, Equatable {
private enum CodingKeys: String, CodingKey {
case host
case port
case tls
case contextPath
}
public let host: String
public let port: Int
public let tls: Bool
public let contextPath: String?
public init(host: String, port: Int, tls: Bool) {
public init(host: String, port: Int, tls: Bool, contextPath: String? = nil) {
self.host = host
self.port = port
self.tls = tls
self.contextPath = normalizeGatewayContextPath(contextPath)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.host = try container.decode(String.self, forKey: .host)
self.port = try container.decode(Int.self, forKey: .port)
self.tls = try container.decode(Bool.self, forKey: .tls)
self.contextPath = try normalizeGatewayContextPath(
container.decodeIfPresent(String.self, forKey: .contextPath))
}
public var websocketURL: URL? {
guard (1...65535).contains(self.port) else { return nil }
var components = URLComponents()
components.scheme = self.tls ? "wss" : "ws"
components.host = self.host
components.port = self.port
components.percentEncodedPath = self.contextPath ?? ""
return components.url
}
}
@@ -17890,6 +17890,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
public let urlsource: String
public let access: AnyCodable?
public let accessdowngraded: Bool?
public let expiresatms: Int?
public init(
setupcode: String,
@@ -17899,7 +17900,8 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
auth: AnyCodable,
urlsource: String,
access: AnyCodable? = nil,
accessdowngraded: Bool? = nil)
accessdowngraded: Bool? = nil,
expiresatms: Int? = nil)
{
self.setupcode = setupcode
self.qrdataurl = qrdataurl
@@ -17909,6 +17911,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
self.urlsource = urlsource
self.access = access
self.accessdowngraded = accessdowngraded
self.expiresatms = expiresatms
}
private enum CodingKeys: String, CodingKey {
@@ -17920,6 +17923,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
case urlsource = "urlSource"
case access
case accessdowngraded = "accessDowngraded"
case expiresatms = "expiresAtMs"
}
}
@@ -106,6 +106,47 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? {
password: nil))
}
@Test func setupCodeAcceptsPairingURLWrapperWithoutLowercasingPayload() {
let payload = #"{"url":"wss://gateway.example:8443","bootstrapToken":"Bootstrap-AbC123"}"#
let code = setupCode(from: payload)
#expect(
GatewayConnectDeepLink.fromSetupCode("oc-pair://\(code)") ==
GatewayConnectDeepLink.fromSetupCode(code))
}
@Test func setupCodePreservesPrimaryGatewayContextPath() {
let payload = #"{"url":"wss://gateway.example/openclaw-gw","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.contextPath == "/openclaw-gw")
#expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw-gw")
}
@Test func setupCodeDecodesGatewayContextPathExactlyOnce() {
let payload = #"{"url":"wss://gateway.example/openclaw%20gateway","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.contextPath == "/openclaw%20gateway")
#expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%20gateway")
}
@Test func setupCodePreservesEscapedGatewayPathDelimiter() {
let payload = #"{"url":"wss://gateway.example/openclaw%2Fgateway","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.contextPath == "/openclaw%2Fgateway")
#expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%2Fgateway")
}
@Test func setupCodePreservesNonUTF8GatewayPathOctet() {
let payload = #"{"url":"wss://gateway.example/openclaw%FFgateway","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.contextPath == "/openclaw%FFgateway")
#expect(link?.websocketURL?.absoluteString == "wss://gateway.example:443/openclaw%FFgateway")
}
@Test func setupCodeAllowsPrivateLanWs() {
let payload = #"{"url":"ws://192.168.1.20:18789","bootstrapToken":"tok"}"#
#expect(
@@ -131,17 +172,18 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? {
}
@Test func setupCodeParsesOrderedGatewayFallbacks() throws {
let payload = #"{"url":"ws://192.168.1.20:18789","urls":["ws://192.168.1.20:18789","wss://gateway.tailnet.ts.net:8443"],"bootstrapToken":"tok"}"#
let payload = #"{"url":"ws://192.168.1.20:18789/lan-gw","urls":["ws://192.168.1.20:18789/lan-gw","wss://gateway.tailnet.ts.net:8443/tailnet-gw"],"bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.connectionEndpoints == [
.init(host: "192.168.1.20", port: 18789, tls: false),
.init(host: "gateway.tailnet.ts.net", port: 8443, tls: true),
.init(host: "192.168.1.20", port: 18789, tls: false, contextPath: "/lan-gw"),
.init(host: "gateway.tailnet.ts.net", port: 8443, tls: true, contextPath: "/tailnet-gw"),
])
#expect(try link?.selectingEndpoint(#require(link?.connectionEndpoints[1])) == .init(
host: "gateway.tailnet.ts.net",
port: 8443,
tls: true,
contextPath: "/tailnet-gw",
bootstrapToken: "tok",
token: nil,
password: nil))
@@ -154,9 +196,35 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? {
GatewayConnectDeepLink.self,
from: Data(payload.utf8))
#expect(link.contextPath == nil)
#expect(link.fallbackEndpoints.isEmpty)
}
@Test func legacyEncodedFallbackEndpointDecodesWithoutContextPath() throws {
let payload = #"{"host":"gateway.example","port":443,"tls":true,"fallbackEndpoints":[{"host":"fallback.example","port":443,"tls":true}]}"#
let link = try JSONDecoder().decode(
GatewayConnectDeepLink.self,
from: Data(payload.utf8))
#expect(link.fallbackEndpoints == [
.init(host: "fallback.example", port: 443, tls: true),
])
}
@Test func setupCodeRejectsGatewayURLMetadata() {
let urls = [
"wss://user@gateway.example/openclaw-gw",
"wss://gateway.example/openclaw-gw?mode=setup",
"wss://gateway.example/openclaw-gw#fragment",
]
for url in urls {
let payload = #"{"url":"\#(url)","bootstrapToken":"tok"}"#
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
}
}
@Test func setupCodeDropsInsecureGatewayFallbacks() {
let payload = #"{"url":"ws://attacker.example:18789","urls":["ws://attacker.example:18789","wss://gateway.tailnet.ts.net"],"bootstrapToken":"tok"}"#
@@ -1 +1 @@
{"contentHash":"40f4454b9a60030b2a1ba050abaad8993e821cb84444365728dc3d12de86e1af","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"71bba45fd19e23bdcad65dc617c22dbdfc7b3c2957b7aec28fd470f4d98de9e1","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"c700bd1f9821574a3d0a20815d55fc8b3857d71cdbfeab0a9bfb394bb81fe745","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"e0f0d842ad89e78e3f40e545821fc39a3fccbdb43f592f8d298dae35b197f792","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"30538b51154ba0bdebf6bf02eefd16c73b7fd1043b074f3595db3a3088bfc98c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"970f98d008137e204aed058afc38196a2f4862ccfcaf0b1aeb35fc565060e80d","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"0c9c99f96d0c050db645580b2bb91405485423e2d7fd14031b101d4b44026537","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"a1f13d0608db5e34ac8a96d65522063ad164f04779905aa95263c6a95ddd8477","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"ad9dc515e2c9ed1c15397a9846c6212c56276c5dda24635be3f315312f077e43","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"f8c7d30e1606d19045fa79d6fad7713cdad0c0da719586fd083630c97caa7a48","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"29089a2b47826afc64a85979f3606d4140aa272a77e90247dd92e65bca3a2dd8","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"7b9539b83b719a681f4ad601650bd4eda9d313e2cbc1d8f2caa18429e3204f6e","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"1eda9bc8cdf2adff5b1c04f0d9eaafed8c448bccae1728ad7a6475040e48c960","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"0912d9c29be111d7899d426420841f63efd4b59f2c905d0b0f676522987a7435","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"ca227941dce03110d71765ad1d2e8df89e30d09c19f664082d4e2a113937211b","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"dab3cc4ad5d01284c3458191168aad2eb829731334b5c2bc58909eed9df05a7f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"6de4593681bd8424e7334550612c4e1e9962e1a4dda50effc6890da7a4076fe5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"1aee60635c4552486cdd7a14e9767d39aef9ba90e38db8064a4bfa8d68ab8df3","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"225e30b4d18d4c77aee34623d9e69263835eddf690d0ad6d9dc1285e8f349d06","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"9e84fb5aa07d64518232ebf5d169f0a05d82789b1d4b3c2ade005a3c055921ad","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"b156cd10587667ccf02b0fd7e066de0cf37cd219f0c07c8fb065c293e1f675de","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"12fcbb778c804d28f2cb15077026e97edc13250b55b8fe7934d4e50d1c184fe1","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"1e566fe360e6b82b1d31ded008160ddbfbc97713d3d55f1b9f3ba641825d537c","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"805c19a024cee0370a81b3b8d60a88276b7f2ccfc3288e3082184e6d0503b4cc","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"05d6916c82b9a5b512fd541e68a56040aee9a8a23019e6c70e8a62cdcee16716","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"90de46f1e0f51185b27f551eb1a90a5cba679927da65b45bccdd2adb88b7cff6","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"0d68fc98d2c75f2dc4d74670b8d902000c7af2945b65aeae2f783a8e705675a7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"169d46618a3fe5095c199ed12ef6bba91f64ec0cac95003dba9b61639c1b4c06","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"34eb7210106298e9efc89a2891438dd05e198a1b6a7510f0c948c4f916b752ca","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"ffa80d2beeb2d3b6e3aff7520da7fa500b2b91d4466b636ea546096472d15e44","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"e573cfb3d9ee7c9f79aee3f425fd804953e9df34283196006033dea6ee302396","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"14e690663ce3426b199897c3e08bc8fcde45e3ef34d52acf8ae55d5e837eb738","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"baecdbe479ff6b3a7ae95d67ce3539d61acd424cea71692a04276972884ea372","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"886fac651c8ee8a85ea50cc883c475a966b66d91e7e65f01b08c995c20e8add7","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+1 -1
View File
@@ -139,7 +139,7 @@ creates a device pairing request that must be approved.
Use an already connected Control UI session with `operator.admin` access:
1. Open the Control UI and go to **Settings → Devices**.
2. On the **Devices** page, click **Pair mobile device**.
2. On the **Devices** page, click **Pair device**.
3. Keep **Full access (recommended)**, or select **Limited access** to omit
administrative Gateway controls.
4. Click **Create setup code**.
+15
View File
@@ -74,9 +74,18 @@ Disable it on the node if needed:
openclaw node run --host <gateway-host> --port 18789
```
Or paste a short-lived node setup link from the Control UI Devices page:
```bash
openclaw node run --pair "oc-pair://<setup-code>"
```
Options:
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
- `--pair <code-or-url>`: Read the Gateway endpoint, bootstrap token, TLS mode,
and optional certificate pin from a setup code or `oc-pair://` URL. Explicit
gateway flags override values from `--pair`.
- `--port <port>`: Gateway WebSocket port (default: `18789`)
- `--context-path <path>`: Gateway WebSocket context path (e.g. `/openclaw-gw`). Appended to the WebSocket URL.
- `--tls`: Use TLS for the gateway connection
@@ -87,6 +96,12 @@ Options:
## Gateway auth for node host
`--pair` uses a 10-minute single-use bootstrap token for the first connection.
After pairing, reconnects use the durable device credential. The setup link
does not pre-approve `system.run`; normal node approval and SSH verification
remain in force. `node install --pair` is intentionally unavailable because a
short-lived bearer setup link must not be persisted in service arguments.
`openclaw node run` and `openclaw node install` resolve gateway auth from config/env (no `--token`/`--password` flags on node commands):
- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first.
+22
View File
@@ -38,6 +38,28 @@ Pending requests expire automatically **5 minutes after the node's last
retry** — an actively reconnecting node keeps its one pending request alive
rather than generating a fresh request (and approval prompt) per attempt.
## One-paste node pairing
In the Control UI Devices page, open the pairing dialog, choose **Node host**,
and copy the generated command to the device:
```bash
openclaw node run --pair "oc-pair://<setup-code>"
```
The setup link carries the Gateway endpoint, a short-lived single-use bootstrap
token, and a TLS certificate pin when the Gateway directly serves a pinnable
leaf certificate. The bootstrap token expires after 10 minutes. Explicit
`--host`, `--port`, `--context-path`, `--tls`/`--no-tls`, and
`--tls-fingerprint` flags override values from `--pair`.
The bootstrap token and resulting device credential are separate, like a
short-lived Tailscale auth key and the durable device identity it admits.
Revoking or expiring the setup link does not revoke the paired device; remove
the device separately when needed. The link never pre-approves `system.run` or
folder sync. Those operations still use pending approval or
[SSH-verified device auto-approval](#ssh-verified-device-auto-approval-default).
## CLI workflow (headless friendly)
```bash
+15 -1
View File
@@ -94,7 +94,21 @@ On the node machine:
openclaw node run --host <gateway-host> --port 18789 --display-name "Build Node"
```
`node run` also accepts `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint <sha256>`, and `--node-id` (override the legacy client instance ID; this does not reset pairing). On macOS, pass `--share-installed-apps` to advertise `device.apps`; sharing is off by default. Use `--no-share-installed-apps` to disable a previously saved opt-in.
For one-paste setup, create a **Node host** setup link from the Control UI
Devices page, then run its copyable command on the node machine:
```bash
openclaw node run --pair "oc-pair://<setup-code>"
```
The link is single-use and expires after 10 minutes. It supplies the endpoint,
bootstrap token, TLS mode, and certificate pin when available. Explicit
gateway flags override the corresponding `--pair` values. Pairing does not
pre-approve command execution; the first `system.run` request still follows
the normal pending-approval or SSH-verification path. See
[Node pairing](/gateway/pairing#one-paste-node-pairing).
`node run` also accepts `--pair`, `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint <sha256>`, and `--node-id` (override the legacy client instance ID; this does not reset pairing). On macOS, pass `--share-installed-apps` to advertise `device.apps`; sharing is off by default. Use `--no-share-installed-apps` to disable a previously saved opt-in.
### Remote gateway via SSH tunnel (loopback bind)
+1 -1
View File
@@ -53,7 +53,7 @@ Gateway has not been configured yet, run `openclaw onboard` first so setup-code
creation has a token or password auth path.
2. Open the [Control UI](/web/control-ui), select **Nodes**, and click
**Pair mobile device** on the **Devices** page. Full access is recommended
**Pair device** on the **Devices** page. Full access is recommended
and selected by default; choose Limited access only when you want to omit
administrative Gateway controls, then click **Create setup code**.
+1 -1
View File
@@ -98,7 +98,7 @@ An already paired administrator can create the iOS/Android connection QR without
<Steps>
<Step title="Open mobile pairing">
Select **Devices**, then click **Pair mobile device** in the **Devices** card.
Select **Devices**, then click **Pair device** in the **Devices** card.
</Step>
<Step title="Connect the phone">
In the OpenClaw mobile app, open **Settings****Gateway** and scan the QR code. You can copy and paste the setup code instead.
@@ -119,21 +119,24 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => {
expect(plan.device?.signedAt).toBe(123);
});
it("never persists bootstrap or shared-secret credentials", async () => {
it("uses only the preferred bootstrap credential and never persists it", async () => {
const sign = vi.fn(async () => "signature");
const store = vi.fn();
const lifecycle = new GatewayBrowserDeviceAuthLifecycle({
loadIdentity: async () => ({
deviceId: "device",
publicKey: "public",
sign: async () => "signature",
sign,
}),
tokenStore: { load: () => null, store, clear: vi.fn() },
nowMs: () => 123,
});
const plan = await lifecycle.buildPlan({
client,
role: "operator",
defaultScopes: ["operator.read"],
bootstrapScopes: ["operator.read", "operator.write"],
token: "test-shared-token",
bootstrapToken: "test-bootstrap-token",
password: "test-password",
preferBootstrapToken: true,
@@ -141,7 +144,12 @@ describe("GatewayBrowserDeviceAuthLifecycle", () => {
});
expect(plan.auth?.bootstrapToken).toBe("test-bootstrap-token");
expect(plan.auth?.password).toBe("test-password");
expect(plan.auth?.token).toBeUndefined();
expect(plan.auth?.password).toBeUndefined();
expect(plan.selectedAuth.signatureToken).toBe("test-bootstrap-token");
expect(sign).toHaveBeenCalledWith(
"v3|device|openclaw-browser-copilot|ui|operator|operator.read,operator.write|123|test-bootstrap-token|nonce|chrome|extension",
);
await lifecycle.acceptHello({ auth: { role: "operator", scopes: [] } }, plan);
expect(store).not.toHaveBeenCalled();
});
+12
View File
@@ -285,6 +285,7 @@ export type GatewayClientCloseInfo = {
phase: "pre-hello" | "post-hello";
socketOpened: boolean;
transportValidated: boolean;
connectRequestSent?: boolean;
transientPreHelloCleanClose: boolean;
connectError?: Error;
};
@@ -337,6 +338,8 @@ export type GatewayClientOptions = {
requestTimeoutMs?: number;
token?: string;
bootstrapToken?: string;
/** Prefer one setup credential for the first successful device-auth exchange. */
preferBootstrapToken?: boolean;
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
@@ -1037,6 +1040,13 @@ export class GatewayClient {
env: this.opts.env,
});
}
if (this.opts.preferBootstrapToken) {
// The setup credential is single-use; reconnects must use the stored device token.
this.opts.token = undefined;
this.opts.bootstrapToken = undefined;
this.opts.password = undefined;
this.opts.preferBootstrapToken = false;
}
this.tickIntervalMs =
typeof helloOk.policy?.tickIntervalMs === "number" ? helloOk.policy.tickIntervalMs : 30_000;
if (reconnectWithCurrentNodeProtocol) {
@@ -1219,6 +1229,7 @@ export class GatewayClient {
phase: context.helloReceived ? "post-hello" : "pre-hello",
socketOpened: context.socketOpened,
transportValidated: this.transportValidated,
connectRequestSent: context.connectRequestSent,
transientPreHelloCleanClose:
!context.helloReceived && context.code === 1000 && context.reason === "",
...(context.connectFailure?.error ? { connectError: context.connectFailure.error } : {}),
@@ -1336,6 +1347,7 @@ export class GatewayClient {
return selectGatewayConnectAuth({
token: this.opts.token,
bootstrapToken: this.opts.bootstrapToken,
preferBootstrapToken: this.opts.preferBootstrapToken,
deviceToken: this.opts.deviceToken,
password: this.opts.password,
approvalRuntimeToken: this.approvalRuntimeTokenCompatibilityDisabled
+5 -1
View File
@@ -43,7 +43,11 @@ export function selectGatewayConnectAuth(params: {
const storedToken = normalized(params.storedToken);
const stored = { storedToken, storedScopes: params.storedScopes };
if (params.preferBootstrapToken && bootstrapToken) {
return { authBootstrapToken: bootstrapToken, authPassword, ...stored };
return {
authBootstrapToken: bootstrapToken,
signatureToken: bootstrapToken,
...stored,
};
}
const useRetryToken =
params.pendingDeviceTokenRetry === true &&
@@ -113,6 +113,7 @@ export const DevicePairSetupCodeResultSchema = closedObject({
Type.Union([Type.Literal("full"), Type.Literal("limited"), Type.Literal("node")]),
),
accessDowngraded: Type.Optional(Type.Boolean()),
expiresAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
});
// Wire types derive directly from local schema consts so public d.ts graphs never
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { encodePairingSetupCode } from "../../pairing/setup-code.js";
import { resolveNodeGatewayOptions, resolveNodePairGatewayOptions } from "./gateway-options.js";
describe("node gateway options", () => {
it("preserves ordered pairing endpoint candidates and pins only the direct endpoint", () => {
const pair = resolveNodePairGatewayOptions(
encodePairingSetupCode({
url: "wss://192.168.1.20:8443/openclaw-gw",
urls: ["wss://192.168.1.20:8443/openclaw-gw", "wss://gateway.tailnet.example/tailnet-gw"],
bootstrapToken: "bootstrap-123",
tlsFingerprint: "sha256:direct-leaf",
}),
);
expect(resolveNodeGatewayOptions({}, null, pair).gatewayCandidates).toEqual([
{
host: "192.168.1.20",
port: 8443,
contextPath: "/openclaw-gw",
tls: true,
tlsFingerprint: "sha256:direct-leaf",
},
{
host: "gateway.tailnet.example",
port: 443,
contextPath: "/tailnet-gw",
tls: true,
},
]);
expect(resolveNodeGatewayOptions({}, null, pair).contextPath).toBe("/openclaw-gw");
});
it("keeps origin-only pairing endpoints pathless", () => {
const pair = resolveNodePairGatewayOptions(
encodePairingSetupCode({
url: "wss://gateway.example",
bootstrapToken: "bootstrap-123",
}),
);
expect(resolveNodeGatewayOptions({}, null, pair)).toMatchObject({
contextPath: undefined,
gatewayCandidates: [{ host: "gateway.example", port: 443, tls: true }],
});
});
it("collapses pairing candidates when an endpoint flag is explicit", () => {
const pair = resolveNodePairGatewayOptions(
encodePairingSetupCode({
url: "ws://192.168.1.20:18789",
urls: ["ws://192.168.1.20:18789", "wss://gateway.tailnet.example"],
bootstrapToken: "bootstrap-123",
}),
);
expect(resolveNodeGatewayOptions({ host: "manual.example" }, null, pair)).toMatchObject({
host: "manual.example",
gatewayCandidates: undefined,
});
});
});
+66 -10
View File
@@ -1,5 +1,6 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { NodeHostConfig } from "../../node-host/config.js";
import type { NodeHostConfig, NodeHostGatewayConfig } from "../../node-host/config.js";
import { decodePairingSetupCode } from "../../pairing/setup-code.js";
import { parsePort } from "../daemon-cli/shared.js";
type NodeGatewayOptions = {
@@ -10,29 +11,84 @@ type NodeGatewayOptions = {
tlsFingerprint?: string;
};
type NodePairGatewayOptions = {
host: string;
port: number;
contextPath?: string;
tls: boolean;
tlsFingerprint?: string;
bootstrapToken: string;
candidates: NodeHostGatewayConfig[];
};
function gatewayConfigFromUrl(url: string, tlsFingerprint?: string): NodeHostGatewayConfig {
const parsed = new URL(url);
const tls = parsed.protocol === "wss:";
return {
host: parsed.hostname,
port: parsed.port ? Number.parseInt(parsed.port, 10) : tls ? 443 : 80,
...(parsed.pathname !== "/" ? { contextPath: parsed.pathname } : {}),
tls,
...(tlsFingerprint ? { tlsFingerprint } : {}),
};
}
export function resolveNodePairGatewayOptions(input: string): NodePairGatewayOptions {
const payload = decodePairingSetupCode(input);
const candidates = (payload.urls ?? [payload.url]).map((url) =>
gatewayConfigFromUrl(url, url === payload.url ? payload.tlsFingerprint : undefined),
);
const primary = candidates[0]!;
return {
host: primary.host ?? "127.0.0.1",
port: primary.port ?? 18789,
...(primary.contextPath ? { contextPath: primary.contextPath } : {}),
tls: primary.tls ?? false,
...(primary.tlsFingerprint ? { tlsFingerprint: primary.tlsFingerprint } : {}),
bootstrapToken: payload.bootstrapToken,
candidates,
};
}
export function resolveNodeGatewayOptions(
options: NodeGatewayOptions,
config: NodeHostConfig | null,
pair?: NodePairGatewayOptions,
) {
const savedHost = config?.gateway?.host || "127.0.0.1";
const savedPort = config?.gateway?.port ?? 18789;
const host = normalizeOptionalString(options.host) || savedHost;
const port = options.port === undefined ? savedPort : parsePort(options.port);
const endpointChanged = host !== savedHost || (port !== null && port !== savedPort);
const baselineHost = pair?.host ?? config?.gateway?.host ?? "127.0.0.1";
const baselinePort = pair?.port ?? config?.gateway?.port ?? 18789;
const host = normalizeOptionalString(options.host) || baselineHost;
const port = options.port === undefined ? baselinePort : parsePort(options.port);
const endpointChanged = host !== baselineHost || (port !== null && port !== baselinePort);
const baselineTlsFingerprint = pair?.tlsFingerprint ?? config?.gateway?.tlsFingerprint;
const baselineTls = pair?.tls ?? config?.gateway?.tls;
const tlsFingerprint =
options.tls === false
? undefined
: (normalizeOptionalString(options.tlsFingerprint) ??
(endpointChanged ? undefined : config?.gateway?.tlsFingerprint));
(endpointChanged ? undefined : baselineTlsFingerprint));
const tls =
typeof options.tls === "boolean"
? options.tls
: Boolean(tlsFingerprint) || (endpointChanged ? undefined : config?.gateway?.tls);
: Boolean(tlsFingerprint) || (endpointChanged ? undefined : baselineTls);
const contextPath =
normalizeOptionalString(options.contextPath) ??
(options.contextPath !== undefined || endpointChanged
? undefined
: config?.gateway?.contextPath);
: (pair?.contextPath ?? config?.gateway?.contextPath));
const hasExplicitEndpoint =
options.host !== undefined ||
options.port !== undefined ||
options.contextPath !== undefined ||
options.tls !== undefined ||
options.tlsFingerprint !== undefined;
return { host, port, contextPath, tls, tlsFingerprint };
return {
host,
port,
contextPath,
tls,
tlsFingerprint,
gatewayCandidates: pair && !hasExplicitEndpoint ? pair.candidates : undefined,
};
}
+81
View File
@@ -1,6 +1,7 @@
// Node CLI register tests cover node command registration and option wiring.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { encodePairingSetupCode } from "../../pairing/setup-code.js";
import { registerNodeCli } from "./register.js";
type LoadNodeHostConfig = typeof import("../../node-host/config.js").loadNodeHostConfig;
@@ -106,6 +107,86 @@ describe("registerNodeCli", () => {
);
});
it("derives the node endpoint, TLS pin, and bootstrap credential from --pair", async () => {
const setupCode = encodePairingSetupCode({
url: "wss://gateway.example:8443/openclaw-gw",
bootstrapToken: "bootstrap-123",
tlsFingerprint: "sha256:pair-leaf",
});
await createProgram().parseAsync(["node", "run", "--pair", `oc-pair://${setupCode}`], {
from: "user",
});
expect(daemonMocks.runNodeHost).toHaveBeenCalledWith(
expect.objectContaining({
gatewayHost: "gateway.example",
gatewayPort: 8443,
gatewayContextPath: "/openclaw-gw",
gatewayTls: true,
gatewayTlsFingerprint: "sha256:pair-leaf",
gatewayCandidates: [
{
host: "gateway.example",
port: 8443,
contextPath: "/openclaw-gw",
tls: true,
tlsFingerprint: "sha256:pair-leaf",
},
],
gatewayBootstrapToken: "bootstrap-123",
preferGatewayBootstrapToken: true,
}),
);
});
it("lets explicit gateway flags override --pair values", async () => {
const setupCode = encodePairingSetupCode({
url: "wss://paired.example:8443",
bootstrapToken: "bootstrap-123",
tlsFingerprint: "sha256:pair-leaf",
});
await createProgram().parseAsync(
[
"node",
"run",
"--pair",
setupCode,
"--host",
"explicit.example",
"--port",
"19000",
"--tls-fingerprint",
"sha256:explicit-leaf",
],
{ from: "user" },
);
expect(daemonMocks.runNodeHost).toHaveBeenCalledWith(
expect.objectContaining({
gatewayHost: "explicit.example",
gatewayPort: 19000,
gatewayTls: true,
gatewayTlsFingerprint: "sha256:explicit-leaf",
gatewayCandidates: undefined,
gatewayBootstrapToken: "bootstrap-123",
}),
);
});
it("rejects an invalid --pair value before loading node state", async () => {
await createProgram().parseAsync(["node", "run", "--pair", "not-a-setup-code"], {
from: "user",
});
expect(daemonMocks.runNodeHost).not.toHaveBeenCalled();
expect(daemonMocks.loadNodeHostConfig).not.toHaveBeenCalled();
expect(daemonMocks.defaultRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("Invalid pairing setup"),
);
});
it.each([
["host", ["--host", "10.0.0.2"]],
["port", ["--port", "19001"]],
+18 -5
View File
@@ -16,7 +16,7 @@ import {
runNodeDaemonStop,
runNodeDaemonUninstall,
} from "./daemon.js";
import { resolveNodeGatewayOptions } from "./gateway-options.js";
import { resolveNodeGatewayOptions, resolveNodePairGatewayOptions } from "./gateway-options.js";
import { runNodeIdentityShow } from "./identity.js";
export function registerNodeCli(program: Command) {
@@ -48,6 +48,10 @@ export function registerNodeCli(program: Command) {
node
.command("run")
.description("Run the headless node host (foreground)")
.option(
"--pair <code-or-url>",
"Pair with a setup code or oc-pair URL; explicit gateway flags take precedence",
)
.option("--host <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--context-path <path>", "Gateway WebSocket context path (e.g. /openclaw-gw)")
@@ -59,11 +63,17 @@ export function registerNodeCli(program: Command) {
.option("--share-installed-apps", "Share installed macOS applications with the Gateway")
.option("--no-share-installed-apps", "Disable installed application sharing")
.action(async (opts) => {
let pair;
try {
pair = opts.pair ? resolveNodePairGatewayOptions(opts.pair) : undefined;
} catch (error) {
defaultRuntime.error(error instanceof Error ? error.message : String(error));
defaultRuntime.exit(1);
return;
}
const existing = await loadNodeHostConfig();
const { host, port, contextPath, tls, tlsFingerprint } = resolveNodeGatewayOptions(
opts,
existing,
);
const { host, port, contextPath, tls, tlsFingerprint, gatewayCandidates } =
resolveNodeGatewayOptions(opts, existing, pair);
if (port === null) {
defaultRuntime.error(formatInvalidPortOption("--port"));
defaultRuntime.exit(1);
@@ -80,6 +90,9 @@ export function registerNodeCli(program: Command) {
gatewayTls: tls,
gatewayTlsFingerprint: tlsFingerprint,
gatewayContextPath: contextPath,
gatewayCandidates,
gatewayBootstrapToken: pair?.bootstrapToken,
preferGatewayBootstrapToken: pair !== undefined,
nodeId: opts.nodeId,
displayName: opts.displayName,
installedAppsSharing: opts.shareInstalledApps,
+5
View File
@@ -159,6 +159,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url,
bootstrapToken: "bootstrap-123",
expiresAtMs: 123,
});
expect(runtime.log).toHaveBeenCalledWith(expected);
}
@@ -209,6 +210,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://127.0.0.1:18789",
bootstrapToken: "bootstrap-123",
expiresAtMs: 123,
});
expect(runtime.log).toHaveBeenCalledWith(expected);
expect(renderTerminal).not.toHaveBeenCalled();
@@ -290,6 +292,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "ws://127.0.0.1:18789",
bootstrapToken: "bootstrap-123",
expiresAtMs: 123,
});
expect(renderTerminal).toHaveBeenCalledWith(expected, { small: true });
const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
@@ -495,6 +498,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444",
bootstrapToken: "bootstrap-123",
expiresAtMs: 123,
});
expect(runtime.log).toHaveBeenCalledWith(expected);
const request = resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as
@@ -557,6 +561,7 @@ describe("registerQrCli", () => {
const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444",
bootstrapToken: "bootstrap-123",
expiresAtMs: 123,
});
expect(runtime.log).toHaveBeenCalledWith(expected);
});
+5
View File
@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { trimToUndefined } from "../gateway/credentials.js";
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import { renderQrTerminal } from "../media/qr-terminal.ts";
import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js";
import { runCommandWithTimeout } from "../process/exec.js";
@@ -220,6 +221,10 @@ export function registerQrCli(program: Command) {
await runCommandWithTimeout(argv, {
timeoutMs: runOpts.timeoutMs,
}),
loadLocalTlsFingerprint: async () => {
const tls = await loadGatewayTlsRuntime(cfg.gateway?.tls);
return tls.enabled ? tls.fingerprintSha256 : undefined;
},
});
if (!resolved.ok) {
+51
View File
@@ -1,4 +1,9 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { DeviceAuthTokenRecord } from "../../packages/gateway-client/src/client.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js";
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { startMinimalRealGateway } from "../gateway/minimal-gateway.test-helpers.js";
import type { TuiSessionList } from "../tui/tui-backend.js";
@@ -218,4 +223,50 @@ describe("real Gateway session boundary", () => {
}),
);
});
it("retires the one-use bootstrap credential before a real-wire reconnect", async () => {
const { GatewayClient } =
await vi.importActual<typeof import("../gateway/client.js")>("../gateway/client.js");
const authState: { value: DeviceAuthTokenRecord | null } = { value: null };
const storeDeviceAuthToken = vi.fn(({ token, scopes }: { token: string; scopes: string[] }) => {
authState.value = { token, scopes };
});
let helloCount = 0;
const client = new GatewayClient({
url: harness.url,
bootstrapToken: await harness.issueNodeBootstrapToken(),
preferBootstrapToken: true,
role: "node",
scopes: [],
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientVersion: "test",
platform: "test",
mode: GATEWAY_CLIENT_MODES.NODE,
deviceIdentity: harness.createDeviceIdentity("reconnect"),
hostDeps: {
loadDeviceAuthToken: () => authState.value,
storeDeviceAuthToken,
},
onHelloOk: () => {
helloCount += 1;
},
});
client.start();
try {
await vi.waitFor(() => expect(helloCount).toBe(1), { timeout: 5_000 });
expect(storeDeviceAuthToken).toHaveBeenCalledOnce();
expect(storeDeviceAuthToken).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.stringMatching(/\S/),
scopes: expect.any(Array),
}),
);
expect(authState.value?.token).toBeTruthy();
await harness.restart();
await vi.waitFor(() => expect(helloCount).toBe(2), { timeout: 5_000 });
} finally {
await client.stopAndWait();
}
});
});
+3
View File
@@ -125,6 +125,7 @@ function startStubGatewayClient() {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: true,
});
lastClientOptions?.onHelloOk?.(makeStubGatewayHello());
@@ -133,12 +134,14 @@ function startStubGatewayClient() {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: true,
});
lastClientOptions?.onClose?.(1000, "", {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: true,
});
} else if (startMode === "connect-error") {
+1 -1
View File
@@ -16,7 +16,7 @@ const ALLOWED_GATEWAY_CLIENT_CALLSITES = new Set([
"src/gateway/gateway-cli-backend.live-helpers.ts",
"src/gateway/operator-approvals-client.ts",
"src/gateway/probe.ts",
"src/node-host/runner.ts",
"src/node-host/gateway-candidate-connection.ts",
"src/tui/gateway-chat.ts",
]);
+71
View File
@@ -745,6 +745,7 @@ describe("GatewayClient close handling", () => {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
},
);
@@ -768,6 +769,7 @@ describe("GatewayClient close handling", () => {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
client.stop();
@@ -785,6 +787,7 @@ describe("GatewayClient close handling", () => {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
client.stop();
@@ -813,6 +816,7 @@ describe("GatewayClient close handling", () => {
phase: "pre-hello",
socketOpened: true,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
client.stop();
@@ -831,6 +835,7 @@ describe("GatewayClient close handling", () => {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
expect(logDebugMock).toHaveBeenCalledWith(
@@ -895,6 +900,7 @@ describe("GatewayClient close handling", () => {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: true,
});
@@ -981,12 +987,14 @@ describe("GatewayClient close handling", () => {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: true,
});
expect(onClose).toHaveBeenNthCalledWith(2, 1000, "", {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: true,
});
expect(onConnectError).toHaveBeenCalledOnce();
@@ -1106,6 +1114,7 @@ describe("GatewayClient close handling", () => {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
client.stop();
@@ -1174,6 +1183,7 @@ describe("GatewayClient connect auth payload", () => {
maxProtocol?: number;
scopes?: string[];
client?: {
id?: string;
mode?: string;
platform?: string;
};
@@ -2401,6 +2411,66 @@ describe("GatewayClient connect auth payload", () => {
client.stop();
});
it("emits only the signed bootstrap credential in a preferred node-host connect frame", () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stale-device-token" });
const signDevicePayload = vi.fn((_privateKeyPem: string, _payload: string) => "signature");
const client = createClientWithIdentity("device-pairing-bootstrap", vi.fn(), {
token: "shared-token",
bootstrapToken: "bootstrap-token",
password: "shared-password", // pragma: allowlist secret
preferBootstrapToken: true,
role: "node",
mode: GATEWAY_CLIENT_MODES.NODE,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
scopes: [],
hostDeps: { signDevicePayload },
});
const { connect } = startClientAndConnect({ client });
expect(connect.params?.client).toMatchObject({
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
mode: GATEWAY_CLIENT_MODES.NODE,
});
expect(connect.params?.auth).toEqual({ bootstrapToken: "bootstrap-token" });
expect(signDevicePayload.mock.calls[0]?.[1]?.split("|")[7]).toBe("bootstrap-token");
client.stop();
});
it("prefers a paired bootstrap token once, then reconnects with stored device auth", async () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stale-device-token" });
const onHelloOk = vi.fn();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
token: "shared-token",
bootstrapToken: "bootstrap-token",
password: "shared-password", // pragma: allowlist secret
preferBootstrapToken: true,
onHelloOk,
});
const { ws, connect } = startClientAndConnect({ client });
expect(connectFrameFrom(ws)).toMatchObject({ bootstrapToken: "bootstrap-token" });
expect(connectFrameFrom(ws).token).toBeUndefined();
expect(connectFrameFrom(ws).deviceToken).toBeUndefined();
loadDeviceAuthTokenMock.mockReturnValue({ token: "issued-device-token" });
emitHelloOk(ws, connect.id);
await waitForFast(() => expect(onHelloOk).toHaveBeenCalledOnce());
ws.emitClose(1006, "socket lost");
await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
const reconnect = getLatestWs();
reconnect.emitOpen();
emitConnectChallenge(reconnect, "nonce-reconnect");
expect(connectFrameFrom(reconnect)).toMatchObject({
token: "issued-device-token",
deviceToken: "issued-device-token",
});
expect(connectFrameFrom(reconnect).password).toBeUndefined();
expect(connectFrameFrom(reconnect).bootstrapToken).toBeUndefined();
client.stop();
});
it("prefers explicit deviceToken over stored device token", () => {
loadDeviceAuthTokenMock.mockReturnValue({
token: "stored-device-token",
@@ -2600,6 +2670,7 @@ describe("GatewayClient connect auth payload", () => {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: false,
});
});
+40 -23
View File
@@ -84,8 +84,9 @@ export async function startMinimalRealGateway(
visibility?: import("../config/sessions.js").SessionEntry["visibility"];
}> = [],
) {
const [bootstrap, profiles, sessionStore, testState] = await Promise.all([
const [bootstrap, deviceIdentity, profiles, sessionStore, testState] = await Promise.all([
import("../infra/device-bootstrap.js"),
import("../infra/device-identity.js"),
import("../shared/device-bootstrap-profile.js"),
import("../config/sessions/session-accessor.sqlite-entry.js"),
import("../test-utils/openclaw-test-state.js"),
@@ -112,6 +113,23 @@ export async function startMinimalRealGateway(
while (port === 18789) {
port = await getFreePort();
}
const startServer = async () => {
const methods = await import("./server-methods.js");
const original = methods.coreGatewayHandlers["sessions.list"]!;
methods.coreGatewayHandlers["sessions.list"] = async (options) => {
sessionListRequests.push(options.params as Record<string, unknown>);
return await original(options);
};
const gateway = await import("./server.js");
return await gateway
.startGatewayServer(port, {
auth: { mode: "token", token },
bind: "loopback",
controlUiEnabled: false,
sidecarStartup: "defer",
})
.finally(() => (methods.coreGatewayHandlers["sessions.list"] = original));
};
try {
for (const session of sessions) {
await sessionStore.upsertSessionEntryCore(
@@ -123,21 +141,7 @@ export async function startMinimalRealGateway(
{ sessionId: session.key, updatedAt: Date.now(), visibility: session.visibility },
);
}
const methods = await import("./server-methods.js");
const original = methods.coreGatewayHandlers["sessions.list"]!;
methods.coreGatewayHandlers["sessions.list"] = async (options) => {
sessionListRequests.push(options.params as Record<string, unknown>);
return await original(options);
};
const gateway = await import("./server.js");
server = await gateway
.startGatewayServer(port, {
auth: { mode: "token", token },
bind: "loopback",
controlUiEnabled: false,
sidecarStartup: "defer",
})
.finally(() => (methods.coreGatewayHandlers["sessions.list"] = original));
server = await startServer();
} catch (error) {
await state.cleanup();
throw error;
@@ -149,18 +153,31 @@ export async function startMinimalRealGateway(
sessionListRequests,
hellos,
connectFailures,
connectBootstrap: async (mismatched = false) => {
const helpers = await import("./test-helpers.js");
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
clients.push(ws);
const bootstrapToken = (
issueNodeBootstrapToken: async () =>
(
await bootstrap.issueDeviceBootstrapToken({
baseDir: state.stateDir,
profile: profiles.NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
})
).token;
).token,
createDeviceIdentity: (label: string) =>
deviceIdentity.loadOrCreateDeviceIdentity({
path: state.statePath(`device-${label}.sqlite`),
}),
restart: async () => {
await server!.close({ reason: "test reconnect", restartExpectedMs: 0 });
server = await startServer();
},
connectBootstrap: async (mismatched = false) => {
const helpers = await import("./test-helpers.js");
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
clients.push(ws);
const bootstrapToken = await bootstrap.issueDeviceBootstrapToken({
baseDir: state.stateDir,
profile: profiles.NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const response = await helpers.connectReq(ws, {
bootstrapToken,
bootstrapToken: bootstrapToken.token,
...(mismatched ? { deviceToken: "mismatched-device-token" } : {}),
skipDefaultAuth: true,
role: "node",
+1
View File
@@ -103,6 +103,7 @@ class MockGatewayClient {
phase: "pre-hello",
socketOpened: gatewayClientState.socketOpened,
transportValidated: gatewayClientState.transportValidated,
connectRequestSent: true,
transientPreHelloCleanClose: false,
});
}
@@ -94,6 +94,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
pluginGatewayContext,
getAttachedGatewayMethodRegistry,
gatewayInstanceRuntimeRef,
gatewayTls,
lifecycle,
startupState,
clearFallbackGatewayContextForServer,
@@ -111,6 +112,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
runtimeState,
sessionCompanion,
getRuntimeConfig,
gatewayTlsFingerprint: gatewayTls.enabled ? gatewayTls.fingerprintSha256 : undefined,
sessionObserver,
getMcpAppSandboxPort,
ensureSandboxHostPort,
@@ -43,6 +43,7 @@ function createOptions(
respond,
context: {
getRuntimeConfig: vi.fn(() => config),
gatewayTlsFingerprint: "sha256:gateway-leaf",
},
} as unknown as GatewayRequestHandlerOptions;
return { options, respond };
@@ -59,6 +60,7 @@ const okResolution = {
urlSource: "remote",
access: "full" as const,
accessDowngraded: false,
expiresAtMs: 123_456,
};
describe("device.pair.setupCode", () => {
@@ -95,9 +97,14 @@ describe("device.pair.setupCode", () => {
auth: "token",
urlSource: "remote",
access: "full",
expiresAtMs: 123_456,
});
// The bootstrap token only lives inside the (opaque) setup code, never as a field.
expect(JSON.stringify(payload)).not.toContain("boot-123");
expect(mocks.resolvePairingSetupFromConfig).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ localTlsFingerprint: "sha256:gateway-leaf" }),
);
});
it("reports when plaintext transport limits a requested full-access code", async () => {
@@ -53,6 +53,7 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
env: process.env,
publicUrl,
preferRemoteUrl: params.preferRemoteUrl === true,
localTlsFingerprint: context.gatewayTlsFingerprint,
...(params.bootstrapProfile
? {
bootstrapProfile:
@@ -89,6 +90,7 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
auth: resolved.authLabel,
urlSource: requestPublicUrl ? "request.publicUrl" : resolved.urlSource,
access: resolved.access,
expiresAtMs: resolved.expiresAtMs,
...(resolved.accessDowngraded ? { accessDowngraded: true } : {}),
},
undefined,
@@ -188,6 +188,8 @@ type GatewayKernelContext = {
cron: GatewayCronServiceContract;
cronStorePath: string;
getRuntimeConfig: () => OpenClawConfig;
/** Prepared listener certificate pin; undefined when Gateway TLS is disabled. */
gatewayTlsFingerprint?: string;
sessionCompanion?: import("../session-companion.js").SessionCompanionService;
sessionObserver?: SessionObserverService;
resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution;
+2
View File
@@ -31,6 +31,7 @@ type GatewayRequestContextParams = {
"cronState" | "controlUiSessionPullRequests" | "sessionViewerPresence"
>;
getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"];
gatewayTlsFingerprint?: GatewayRequestContext["gatewayTlsFingerprint"];
sessionCompanion: SessionCompanionService;
sessionObserver: SessionObserverService;
getMcpAppSandboxPort?: GatewayRequestContext["getMcpAppSandboxPort"];
@@ -173,6 +174,7 @@ export function createGatewayRequestContext(
return params.runtimeState.cronState.storePath;
},
getRuntimeConfig: params.getRuntimeConfig,
gatewayTlsFingerprint: params.gatewayTlsFingerprint,
controlUiSessionPullRequests: params.runtimeState.controlUiSessionPullRequests,
sessionViewerPresence: params.runtimeState.sessionViewerPresence,
sessionCompanion: params.sessionCompanion,
@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayClientOptions } from "../gateway/client.js";
import { createNodeHostGatewayCandidateConnection } from "./gateway-candidate-connection.js";
const mocks = vi.hoisted(() => ({
options: [] as GatewayClientOptions[],
clients: [] as Array<{
request: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
updateNodeManifest: ReturnType<typeof vi.fn>;
}>,
}));
vi.mock("../gateway/client.js", () => ({
GatewayClient: function GatewayClient(options: GatewayClientOptions) {
const client = {
request: vi.fn(async () => ({ url: options.url })),
start: vi.fn(),
stop: vi.fn(),
updateNodeManifest: vi.fn(),
};
mocks.options.push(options);
mocks.clients.push(client);
return client;
},
}));
const candidates = [
{ host: "192.168.1.20", port: 18789, contextPath: "/openclaw-gw", tls: false },
{ host: "gateway.tailnet.example", port: 443, tls: true },
];
function createConnection() {
const callbacks = {
onEvent: vi.fn(),
onHelloOk: vi.fn(),
onConnectError: vi.fn(),
onReconnectPaused: vi.fn(),
onClose: vi.fn(),
onWinningCandidate: vi.fn(),
};
return {
callbacks,
connection: createNodeHostGatewayCandidateConnection({
candidates,
clientOptions: {},
...callbacks,
}),
};
}
describe("gateway candidate connection", () => {
beforeEach(() => {
mocks.options.length = 0;
mocks.clients.length = 0;
vi.clearAllMocks();
});
it("rotates only before hello, fences stale callbacks, and forwards through the winner", async () => {
const { callbacks, connection } = createConnection();
connection.start();
expect(mocks.options[0]?.url).toBe("ws://192.168.1.20:18789/openclaw-gw");
expect(mocks.clients[0]?.start).toHaveBeenCalledOnce();
mocks.options[0]?.onClose?.(1006, "transport unavailable", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
await vi.waitFor(() => expect(mocks.clients).toHaveLength(2));
expect(mocks.clients[0]?.stop).toHaveBeenCalledOnce();
expect(mocks.options[1]?.url).toBe("wss://gateway.tailnet.example:443");
expect(mocks.clients[1]?.start).toHaveBeenCalledOnce();
mocks.options[0]?.onEvent?.({ type: "event", event: "stale" });
mocks.options[0]?.onHelloOk?.({} as never);
mocks.options[0]?.onClose?.(1006, "stale close", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
expect(callbacks.onEvent).not.toHaveBeenCalled();
expect(callbacks.onHelloOk).not.toHaveBeenCalled();
expect(callbacks.onWinningCandidate).not.toHaveBeenCalled();
expect(mocks.clients).toHaveLength(2);
const activeEvent = { type: "event", event: "active" } as const;
mocks.options[1]?.onEvent?.(activeEvent);
mocks.options[1]?.onHelloOk?.({} as never);
mocks.options[1]?.onHelloOk?.({} as never);
expect(callbacks.onEvent).toHaveBeenCalledWith(activeEvent);
expect(callbacks.onWinningCandidate).toHaveBeenCalledOnce();
expect(callbacks.onWinningCandidate).toHaveBeenCalledWith(candidates[1]);
await connection.request("node.test", { active: true }, undefined);
connection.updateNodeManifest({ caps: ["mcp"], commands: ["mcp.tools.call.v1"] });
expect(mocks.clients[0]?.request).not.toHaveBeenCalled();
expect(mocks.clients[1]?.request).toHaveBeenCalledWith(
"node.test",
{ active: true },
undefined,
);
expect(mocks.clients[1]?.updateNodeManifest).toHaveBeenCalledWith({
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
});
});
it("does not rotate after the connect request was sent", async () => {
createConnection();
mocks.options[0]?.onClose?.(1008, "connect failed", {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: false,
});
await Promise.resolve();
expect(mocks.clients).toHaveLength(1);
});
it("promotes a candidate after hello instead of replaying setup auth on another endpoint", async () => {
const { callbacks } = createConnection();
mocks.options[0]?.onHelloOk?.({} as never);
mocks.options[0]?.onClose?.(1006, "later reconnect transport failure", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
await Promise.resolve();
expect(callbacks.onWinningCandidate).toHaveBeenCalledWith(candidates[0]);
expect(mocks.clients).toHaveLength(1);
});
it("carries a pre-hello manifest update into the next candidate", async () => {
const { connection } = createConnection();
const manifest = { caps: ["mcp"], commands: ["mcp.tools.call.v1"] };
connection.updateNodeManifest(manifest);
mocks.options[0]?.onClose?.(1006, "transport unavailable", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
await vi.waitFor(() => expect(mocks.clients).toHaveLength(2));
expect(mocks.clients[1]?.updateNodeManifest).toHaveBeenCalledWith(manifest);
});
it("does not create the queued candidate after stop", async () => {
const { connection } = createConnection();
mocks.options[0]?.onClose?.(1006, "transport unavailable", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
connection.stop();
await Promise.resolve();
expect(mocks.clients).toHaveLength(1);
});
});
@@ -0,0 +1,152 @@
import {
GatewayClient,
type GatewayClientCloseInfo,
type GatewayClientOptions,
type GatewayClientRequestOptions,
type GatewayReconnectPausedInfo,
} from "../gateway/client.js";
import type { NodeHostGatewayConfig } from "./config.js";
type GatewayCandidateEvent = Parameters<NonNullable<GatewayClientOptions["onEvent"]>>[0];
type GatewayCandidateHello = Parameters<NonNullable<GatewayClientOptions["onHelloOk"]>>[0];
type CandidateConnectionOptions = Omit<
GatewayClientOptions,
| "url"
| "tlsFingerprint"
| "onEvent"
| "onHelloOk"
| "onConnectError"
| "onReconnectPaused"
| "onClose"
>;
type GatewayCandidateConnectionParams = {
candidates: readonly NodeHostGatewayConfig[];
clientOptions: CandidateConnectionOptions;
onEvent: (event: GatewayCandidateEvent) => void;
onHelloOk: (hello: GatewayCandidateHello, url: string) => void;
onConnectError: (error: Error) => void;
onReconnectPaused: (info: GatewayReconnectPausedInfo) => void;
onClose: (code: number, reason: string, info?: GatewayClientCloseInfo) => void;
onWinningCandidate: (candidate: NodeHostGatewayConfig) => void;
};
function formatGatewayCandidateUrl(gateway: NodeHostGatewayConfig): string {
const host = gateway.host ?? "127.0.0.1";
const urlHost =
host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host;
const port = gateway.port ?? 18789;
const scheme = gateway.tls ? "wss" : "ws";
const contextPath = gateway.contextPath
? gateway.contextPath.startsWith("/")
? gateway.contextPath
: `/${gateway.contextPath}`
: "";
return `${scheme}://${urlHost}:${port}${contextPath}`;
}
function canTryNextGatewayCandidate(info: GatewayClientCloseInfo | undefined): boolean {
return info?.phase === "pre-hello" && info.connectRequestSent === false;
}
export function createNodeHostGatewayCandidateConnection(params: GatewayCandidateConnectionParams) {
if (params.candidates.length === 0) {
throw new Error("node host gateway candidate list cannot be empty");
}
let currentCandidateIndex = 0;
let stopped = false;
let winnerSelected = params.candidates.length === 1;
let latestManifest: { caps: string[]; commands: string[] } | undefined;
let currentClient = createCandidateClient(currentCandidateIndex);
function createCandidateClient(candidateIndex: number): GatewayClient {
const candidate = params.candidates[candidateIndex];
if (!candidate) {
throw new Error(`node host gateway candidate ${candidateIndex} is unavailable`);
}
const url = formatGatewayCandidateUrl(candidate);
const candidateClient = new GatewayClient({
...params.clientOptions,
url,
tlsFingerprint: candidate.tlsFingerprint,
onEvent: (event) => {
if (currentCandidateIndex === candidateIndex) {
params.onEvent(event);
}
},
onHelloOk: (hello) => {
if (currentCandidateIndex !== candidateIndex) {
return;
}
if (!winnerSelected) {
winnerSelected = true;
params.onWinningCandidate(candidate);
}
params.onHelloOk(hello, url);
},
onConnectError: (error) => {
if (currentCandidateIndex === candidateIndex) {
params.onConnectError(error);
}
},
onReconnectPaused: (info) => {
if (currentCandidateIndex === candidateIndex) {
params.onReconnectPaused(info);
}
},
onClose: (code, reason, info) => {
if (currentCandidateIndex !== candidateIndex) {
return;
}
params.onClose(code, reason, info);
const nextCandidateIndex = candidateIndex + 1;
if (
stopped ||
// A successful hello redeems setup credentials and promotes this
// endpoint. Its own reconnect path owns durable device auth from here.
winnerSelected ||
nextCandidateIndex >= params.candidates.length ||
!canTryNextGatewayCandidate(info)
) {
return;
}
currentCandidateIndex = nextCandidateIndex;
candidateClient.stop();
queueMicrotask(() => {
if (stopped || currentCandidateIndex !== nextCandidateIndex) {
return;
}
currentClient = createCandidateClient(nextCandidateIndex);
currentClient.start();
});
},
});
if (latestManifest) {
candidateClient.updateNodeManifest(latestManifest);
}
return candidateClient;
}
return {
start(): void {
currentClient.start();
},
stop(): void {
stopped = true;
currentClient.stop();
},
request<T = Record<string, unknown>>(
...requestArgs: [method: string, params?: unknown, options?: GatewayClientRequestOptions]
): Promise<T> {
return currentClient.request<T>(...requestArgs);
},
updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void {
// Availability may change before the first hello. Every later candidate
// must start with the newest manifest rather than the constructor snapshot.
latestManifest = manifest;
currentClient.updateNodeManifest(manifest);
},
};
}
+88 -1
View File
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
capturedConfiguredGatewayConfigs: [] as Array<{ contextPath?: string }>,
capturedGatewayClients: [] as Array<{
request: Mock<(method: string, params?: unknown) => Promise<unknown>>;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
updateNodeManifest: ReturnType<typeof vi.fn>;
}>,
@@ -30,6 +31,9 @@ const mocks = vi.hoisted(() => ({
availabilityChanged: undefined as (() => void) | undefined,
normalizedPath: null as string | null,
resolvedExecutables: new Map<string, string>(),
runtimeClient: undefined as
| { request: (method: string, params?: unknown) => Promise<unknown> }
| undefined,
closeMcpManager: vi.fn(async () => undefined),
runStartupMigrations: vi.fn(async () => undefined),
configureNodeHost: vi.fn(async (params: Parameters<typeof configureNodeHost>[0]) => {
@@ -76,6 +80,7 @@ vi.mock("../gateway/client.js", async (importOriginal) => {
GatewayClient: function GatewayClient(opts: GatewayClientOptions) {
const client = {
request: vi.fn(async () => ({})),
start: vi.fn(),
stop: vi.fn(),
updateNodeManifest: vi.fn(),
};
@@ -171,7 +176,10 @@ vi.mock("./runtime.js", async (importOriginal) => {
return {
manifest: { caps: [], commands: [], pathEnv: process.env.PATH ?? "" },
initialInventory: { skills: [], pluginTools: [] },
start: () => mocks.activeRuntime,
start: (params) => {
mocks.runtimeClient = params.client;
return mocks.activeRuntime;
},
};
},
};
@@ -207,6 +215,7 @@ describe("runNodeHost", () => {
mocks.availabilityChanged = undefined;
mocks.normalizedPath = null;
mocks.resolvedExecutables.clear();
mocks.runtimeClient = undefined;
vi.clearAllMocks();
mocks.getRuntimeConfig.mockReturnValue({
gateway: { handshakeTimeoutMs: 1_000 },
@@ -246,6 +255,84 @@ describe("runNodeHost", () => {
},
);
it("passes a paired bootstrap credential with first-connect preference", async () => {
await expect(
runNodeHost({
gatewayHost: "gateway.example",
gatewayPort: 443,
gatewayTls: true,
gatewayBootstrapToken: "bootstrap-123",
preferGatewayBootstrapToken: true,
}),
).rejects.toThrow("event loop readiness timeout");
expect(lastCapturedOptions()).toMatchObject({
bootstrapToken: "bootstrap-123",
preferBootstrapToken: true,
});
expect(lastCapturedOptions()?.token).toBeUndefined();
expect(mocks.resolveGatewayCredentialsWithSecretInputs).not.toHaveBeenCalled();
});
it("persists the pairing candidate that completes the handshake", async () => {
mocks.useFakeRuntime = true;
mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({
ready: true,
aborted: false,
elapsedMs: 0,
});
const processOnceSpy = vi.spyOn(process, "once");
const previousExitCode = process.exitCode;
try {
const running = runNodeHost({
gatewayHost: "192.168.1.20",
gatewayPort: 18789,
gatewayBootstrapToken: "bootstrap-123",
preferGatewayBootstrapToken: true,
gatewayCandidates: [
{ host: "192.168.1.20", port: 18789, tls: false },
{ host: "gateway.tailnet.example", port: 443, tls: true },
],
});
await vi.waitFor(() => expect(mocks.capturedGatewayClients).toHaveLength(1));
const firstOptions = mocks.capturedGatewayClientOptions[0];
firstOptions?.onClose?.(1006, "transport unavailable", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
await vi.waitFor(() => expect(mocks.capturedGatewayClients).toHaveLength(2));
expect(mocks.capturedGatewayClientOptions[1]?.url).toBe("wss://gateway.tailnet.example:443");
mocks.capturedGatewayClientOptions[1]?.onHelloOk?.({} as never);
await vi.waitFor(() => expect(mocks.configureNodeHost).toHaveBeenCalledTimes(2));
expect(mocks.capturedConfiguredGatewayConfigs[1]).toEqual({
host: "gateway.tailnet.example",
port: 443,
tls: true,
});
await vi.waitFor(() =>
expect(processOnceSpy.mock.calls.some(([event]) => event === "SIGTERM")).toBe(true),
);
const onSigterm = processOnceSpy.mock.calls.find(([event]) => event === "SIGTERM")?.[1];
onSigterm?.("SIGTERM");
await running;
} finally {
for (const [event, listener] of processOnceSpy.mock.calls) {
if ((event === "SIGINT" || event === "SIGTERM") && typeof listener === "function") {
process.off(event, listener);
}
}
process.exitCode = previousExitCode;
processOnceSpy.mockRestore();
}
});
it("routes invoke input, cancellation, and connection close to the runtime", async () => {
mocks.useFakeRuntime = true;
await expect(runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 })).rejects.toThrow(
+54 -47
View File
@@ -7,16 +7,13 @@ import {
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js";
import {
GatewayClient,
GatewayClientRequestError,
type GatewayReconnectPausedInfo,
} from "../gateway/client.js";
import { GatewayClientRequestError, type GatewayReconnectPausedInfo } from "../gateway/client.js";
import { resolveGatewayCredentialsWithSecretInputs } from "../gateway/credentials-secret-inputs.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js";
import { configureNodeHost, type NodeHostGatewayConfig } from "./config.js";
import { createNodeHostGatewayCandidateConnection } from "./gateway-candidate-connection.js";
import {
coerceNodeInvokeCancelPayload,
coerceNodeInvokeInputPayload,
@@ -30,6 +27,9 @@ type NodeHostRunOptions = {
gatewayPort: number;
gatewayTls?: boolean;
gatewayTlsFingerprint?: string;
gatewayCandidates?: NodeHostGatewayConfig[];
gatewayBootstrapToken?: string;
preferGatewayBootstrapToken?: boolean;
/** Optional WebSocket context path (e.g. "/openclaw-gw"). */
gatewayContextPath?: string;
nodeId?: string;
@@ -220,6 +220,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const nodeId = config.nodeId;
const displayName = config.displayName ?? fallbackDisplayName;
const gateway = config.gateway ?? plannedGateway;
const gatewayCandidates = opts.gatewayCandidates?.length ? opts.gatewayCandidates : [gateway];
const cfg = getRuntimeConfig();
const preparedRuntime = await prepareNodeHostRuntime({
@@ -228,22 +229,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
enableAgentRuns: true,
installedAppsSharingEnabled: config.installedAppsSharing,
});
const { token, password } = await resolveNodeHostGatewayCredentials({
config: cfg,
env: process.env,
});
const { token, password } = opts.preferGatewayBootstrapToken
? {}
: await resolveNodeHostGatewayCredentials({
config: cfg,
env: process.env,
});
const host = gateway.host ?? "127.0.0.1";
const urlHost =
host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host;
const port = gateway.port ?? 18789;
const scheme = gateway.tls ? "wss" : "ws";
const contextPath = gateway.contextPath
? gateway.contextPath.startsWith("/")
? gateway.contextPath
: `/${gateway.contextPath}`
: "";
const url = `${scheme}://${urlHost}:${port}${contextPath}`;
let inventory: NodeHostInventory = preparedRuntime.initialInventory;
let gatewayHelloReceived = false;
let gatewayConnectionGeneration = 0;
@@ -451,27 +443,42 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
);
};
const client = new GatewayClient({
url,
token: token || undefined,
password: password || undefined,
instanceId: nodeId,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: displayName,
clientVersion: VERSION,
platform: resolveNodeHostGatewayPlatform(process.platform),
deviceFamily: resolveNodeHostGatewayDeviceFamily(process.platform),
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
// Pair the built-in MCP command family up front. Server inventory is
// restart-scoped availability, not a capability upgrade requiring re-pairing.
caps: preparedRuntime.manifest.caps,
commands: preparedRuntime.manifest.commands,
pathEnv: preparedRuntime.manifest.pathEnv,
permissions: undefined,
deviceIdentity: loadOrCreateDeviceIdentity(),
tlsFingerprint: gateway.tlsFingerprint,
const persistWinningGateway = (winningGateway: NodeHostGatewayConfig) => {
void configureNodeHost({
nodeId,
displayName,
fallbackDisplayName,
gateway: winningGateway,
installedAppsSharing: config.installedAppsSharing,
}).catch((error: unknown) => {
writeStderrLine(`node host gateway endpoint persistence failed: ${String(error)}`);
});
};
const client = createNodeHostGatewayCandidateConnection({
candidates: gatewayCandidates,
clientOptions: {
token: token || undefined,
bootstrapToken: opts.gatewayBootstrapToken,
preferBootstrapToken: opts.preferGatewayBootstrapToken,
password: password || undefined,
instanceId: nodeId,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: displayName,
clientVersion: VERSION,
platform: resolveNodeHostGatewayPlatform(process.platform),
deviceFamily: resolveNodeHostGatewayDeviceFamily(process.platform),
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
// Pair the built-in MCP command family up front. Server inventory is
// restart-scoped availability, not a capability upgrade requiring re-pairing.
caps: preparedRuntime.manifest.caps,
commands: preparedRuntime.manifest.commands,
pathEnv: preparedRuntime.manifest.pathEnv,
permissions: undefined,
deviceIdentity: loadOrCreateDeviceIdentity(),
},
onEvent: (evt) => {
if (evt.event === "node.invoke.cancel") {
const payload = coerceNodeInvokeCancelPayload(evt.payload);
@@ -491,12 +498,11 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
return;
}
const payload = coerceNodeInvokePayload(evt.payload);
if (!payload) {
return;
if (payload) {
void activeRuntime.invoke(payload);
}
void activeRuntime.invoke(payload);
},
onHelloOk: (hello) => {
onHelloOk: (hello, url) => {
writeStderrLine(`node host gateway connected: ${url}`);
gatewayConnectionGeneration += 1;
gatewayHelloReceived = true;
@@ -505,9 +511,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
optionalPublicationStates = new Map();
publishInventory();
},
onConnectError: (err) => {
onConnectError: (error) => {
// keep retrying (handled by GatewayClient)
writeStderrLine(`node host gateway connect failed: ${err.message}`);
writeStderrLine(`node host gateway connect failed: ${error.message}`);
},
onReconnectPaused: (info) => {
handleNodeHostReconnectPaused(info, {
@@ -524,6 +530,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
activeRuntime.cancelAll();
writeStderrLine(`node host gateway closed (${code}): ${reason}`);
},
onWinningCandidate: persistWinningGateway,
});
const activeRuntime = preparedRuntime.start({
client,
+76 -1
View File
@@ -14,11 +14,41 @@ vi.mock("../infra/device-bootstrap.js", () => ({
})),
}));
const { encodePairingSetupCode, resolvePairingSetupFromConfig } = await import("./setup-code.js");
const { decodePairingSetupCode, encodePairingSetupCode, resolvePairingSetupFromConfig } =
await import("./setup-code.js");
const { issueDeviceBootstrapToken: issueDeviceBootstrapTokenMock } =
await import("../infra/device-bootstrap.js");
describe("pairing setup code", () => {
it("round-trips bare and wrapped setup codes without normalizing payload case", () => {
const payload = {
url: "wss://gateway.example:8443/openclaw-gw",
bootstrapToken: "Bootstrap-AbC123",
tlsFingerprint: "sha256:AA:BB",
expiresAtMs: 20_000,
};
const setupCode = encodePairingSetupCode(payload);
expect(setupCode).toMatch(/[A-Z]/u);
expect(decodePairingSetupCode(setupCode, { nowMs: 10_000 })).toEqual(payload);
expect(decodePairingSetupCode(`oc-pair://${setupCode}`, { nowMs: 10_000 })).toEqual(payload);
});
it("rejects garbage and expired shipped payload shapes", () => {
expect(() => decodePairingSetupCode("not-json")).toThrow("Invalid pairing setup");
const expired = encodePairingSetupCode({
url: "wss://gateway.example",
bootstrapToken: "bootstrap-123",
expiresAtMs: 10_000,
});
expect(() => decodePairingSetupCode(expired, { nowMs: 10_000 })).toThrow("expired");
});
it("accepts older payloads without a TLS fingerprint or expiry", () => {
const payload = { url: "wss://gateway.example", bootstrapToken: "bootstrap-123" };
expect(decodePairingSetupCode(encodePairingSetupCode(payload))).toEqual(payload);
});
type ResolvedSetup = Awaited<ReturnType<typeof resolvePairingSetupFromConfig>>;
type ResolveSetupConfig = Parameters<typeof resolvePairingSetupFromConfig>[0];
type ResolveSetupOptions = Parameters<typeof resolvePairingSetupFromConfig>[1];
@@ -286,6 +316,20 @@ describe("pairing setup code", () => {
});
});
it("preserves context paths in fully qualified setup urls", async () => {
await expectResolvedSetupSuccessCase({
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
options: {
publicUrl: "wss://gateway.example.test:18789/openclaw-gw",
},
expected: {
authLabel: "token",
url: "wss://gateway.example.test:18789/openclaw-gw",
urlSource: "plugins.entries.device-pair.config.publicUrl",
},
});
});
it("issues a node-only bootstrap profile for companion setup", async () => {
await expectResolvedSetupSuccessCase({
config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }),
@@ -938,4 +982,35 @@ describe("pairing setup code", () => {
expectedError: "Service MagicDNS could not be derived",
});
});
it("pins the prepared leaf only for a direct TLS gateway URL", async () => {
const config = createCustomGatewayConfig({ mode: "token", token: "tok_123" });
config.gateway = { ...config.gateway, tls: { enabled: true } };
const direct = await resolvePairingSetupFromConfig(config, {
localTlsFingerprint: "sha256:direct-leaf",
});
const proxied = await resolvePairingSetupFromConfig(config, {
publicUrl: "wss://proxy.example",
localTlsFingerprint: "sha256:direct-leaf",
});
expect(direct.ok && direct.payload.tlsFingerprint).toBe("sha256:direct-leaf");
expect(proxied.ok && proxied.payload.tlsFingerprint).toBeUndefined();
});
it("omits a configured remote TLS pin from a cleartext setup URL", async () => {
const config = createCustomGatewayConfig({ mode: "token", token: "tok_123" });
config.gateway = {
...config.gateway,
remote: {
url: "ws://127.0.0.1:18789",
tlsFingerprint: "sha256:stale-remote-leaf",
},
};
const resolved = await resolvePairingSetupFromConfig(config, { preferRemoteUrl: true });
expect(resolved.ok).toBe(true);
expect(resolved.ok && resolved.payload.tlsFingerprint).toBeUndefined();
});
});
+97 -7
View File
@@ -8,6 +8,7 @@ import {
isRfc1918Ipv4Address,
parseCanonicalIpAddress,
} from "@openclaw/net-policy/ip";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -42,6 +43,8 @@ type PairingSetupPayload = {
url: string;
urls?: string[];
bootstrapToken: string;
expiresAtMs?: number;
tlsFingerprint?: string;
};
type PairingSetupAccess = "full" | "limited" | "node";
@@ -68,6 +71,8 @@ type ResolvePairingSetupOptions = {
pairingBaseDir?: string;
runCommandWithTimeout?: PairingSetupCommandRunner;
networkInterfaces?: () => ReturnType<typeof os.networkInterfaces>;
localTlsFingerprint?: string;
loadLocalTlsFingerprint?: () => Promise<string | undefined>;
};
type PairingSetupResolution =
@@ -78,6 +83,7 @@ type PairingSetupResolution =
urlSource: string;
access: PairingSetupAccess;
accessDowngraded: boolean;
expiresAtMs: number;
}
| {
ok: false;
@@ -239,7 +245,8 @@ function parseNormalizedGatewayUrl(raw: string): string | null {
return null;
}
const port = parsed.port ? `:${parsed.port}` : "";
return `${resolvedScheme}://${host}${port}`;
const contextPath = parsed.pathname === "/" ? "" : parsed.pathname;
return `${resolvedScheme}://${host}${port}${contextPath}`;
} catch {
return null;
}
@@ -409,6 +416,79 @@ export function encodePairingSetupCode(payload: PairingSetupPayload): string {
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
const PAIRING_SETUP_URL_PREFIX = "oc-pair://";
const PAIRING_SETUP_CODE_RE = /^[A-Za-z0-9_-]+$/u;
/** Decode the current setup payload plus additive fields emitted by older pairing surfaces. */
export function decodePairingSetupCode(
input: string,
options: { nowMs?: number } = {},
): PairingSetupPayload {
const trimmed = input.trim();
const setupCode = trimmed.toLowerCase().startsWith(PAIRING_SETUP_URL_PREFIX)
? trimmed.slice(PAIRING_SETUP_URL_PREFIX.length)
: trimmed;
if (!setupCode || !PAIRING_SETUP_CODE_RE.test(setupCode)) {
throw new Error("Invalid pairing setup code or URL.");
}
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(setupCode, "base64url").toString("utf8"));
} catch {
throw new Error("Invalid pairing setup code or URL.");
}
if (!isRecord(decoded)) {
throw new Error("Invalid pairing setup payload.");
}
const url = normalizeOptionalString(decoded.url);
const bootstrapToken = normalizeOptionalString(decoded.bootstrapToken);
if (!url || !bootstrapToken || normalizeUrl(url, "ws") !== url) {
throw new Error("Invalid pairing setup payload.");
}
let urls: string[] | undefined;
if (decoded.urls !== undefined) {
if (
!Array.isArray(decoded.urls) ||
decoded.urls.length === 0 ||
decoded.urls.length > PAIRING_SETUP_MAX_URLS ||
decoded.urls.some(
(candidate) => typeof candidate !== "string" || normalizeUrl(candidate, "ws") !== candidate,
)
) {
throw new Error("Invalid pairing setup payload.");
}
urls = decoded.urls;
}
let expiresAtMs: number | undefined;
if (decoded.expiresAtMs !== undefined) {
const candidate = decoded.expiresAtMs;
if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate < 0) {
throw new Error("Invalid pairing setup payload.");
}
expiresAtMs = candidate;
if (candidate <= (options.nowMs ?? Date.now())) {
throw new Error("Pairing setup code has expired.");
}
}
const tlsFingerprint = normalizeOptionalString(decoded.tlsFingerprint);
if (decoded.tlsFingerprint !== undefined && !tlsFingerprint) {
throw new Error("Invalid pairing setup payload.");
}
return {
url,
...(urls ? { urls } : {}),
bootstrapToken,
...(expiresAtMs !== undefined ? { expiresAtMs } : {}),
...(tlsFingerprint ? { tlsFingerprint } : {}),
};
}
export async function resolvePairingSetupFromConfig(
cfg: OpenClawConfig,
options: ResolvePairingSetupOptions = {},
@@ -476,18 +556,28 @@ export async function resolvePairingSetupFromConfig(
? PAIRING_SETUP_BOOTSTRAP_PROFILE
: requestedBootstrapProfile;
const issuedBootstrap = await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
profile: issuedBootstrapProfile,
});
const directGatewayTlsFingerprint =
urlResult.url.startsWith("wss://") && urlResult.source?.startsWith("gateway.bind=")
? (normalizeOptionalString(options.localTlsFingerprint) ??
(await options.loadLocalTlsFingerprint?.()))
: urlResult.url.startsWith("wss://") && urlResult.source === "gateway.remote.url"
? normalizeOptionalString(cfgForAuth.gateway?.remote?.tlsFingerprint)
: undefined;
return {
ok: true,
payload: {
url: urlResult.url,
...(uniqueUrls.length > 1 ? { urls: uniqueUrls } : {}),
bootstrapToken: (
await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,
profile: issuedBootstrapProfile,
})
).token,
bootstrapToken: issuedBootstrap.token,
expiresAtMs: issuedBootstrap.expiresAtMs,
...(directGatewayTlsFingerprint ? { tlsFingerprint: directGatewayTlsFingerprint } : {}),
},
expiresAtMs: issuedBootstrap.expiresAtMs,
authLabel: authLabel.label,
urlSource: urlResult.source ?? "unknown",
access: resolvePairingSetupAccess(issuedBootstrapProfile),
+74 -31
View File
@@ -3,6 +3,7 @@
import { render, type TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { OpenClawDevicePairSetup } from "../pages/devices/view-pairing.ts";
import type { ApplicationRuntime } from "./bootstrap.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "./context.ts";
import "./app-host.ts";
@@ -23,7 +24,12 @@ function createPairingShell(params: {
auth: PairingAuth | null;
connected?: boolean;
setupCode?: string;
expiresAtMs?: number;
approvalNowMs?: number;
}) {
if (!customElements.get("openclaw-device-pair-setup")) {
customElements.define("openclaw-device-pair-setup", OpenClawDevicePairSetup);
}
const snapshot: ApplicationGatewaySnapshot = {
client: { request: vi.fn(async () => ({})) } as unknown as GatewayBrowserClient,
phase: params.connected === false ? "stopped" : "connected",
@@ -36,6 +42,30 @@ function createPairingShell(params: {
lastErrorCode: null,
};
const openDevicePairSetup = vi.fn(async () => undefined);
const overlaySnapshot = {
approvalQueue: [],
approvalErrors: new Map(),
approvalNowMs: params.approvalNowMs ?? 0,
approvalBusy: false,
devicePairSetupOpen: Boolean(params.setupCode),
devicePairSetupLoading: false,
devicePairSetupError: null,
devicePairSetup: params.setupCode
? {
setupCode: params.setupCode,
gatewayUrl: "wss://gateway.example.test",
auth: "token",
urlSource: "test",
...(params.expiresAtMs === undefined ? {} : { expiresAtMs: params.expiresAtMs }),
}
: null,
devicePairSetupAccess: "full",
devicePairPendingCount: 0,
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
controlUiRefreshRequired: false,
};
const context = {
basePath: "",
gateway: {
@@ -46,29 +76,7 @@ function createPairingShell(params: {
snapshot: { navCollapsed: false, navWidth: 258, sidebarEntries: [], pinnedAgentIds: [] },
},
overlays: {
snapshot: {
approvalQueue: [],
approvalErrors: new Map(),
approvalNowMs: 0,
approvalBusy: false,
devicePairSetupOpen: Boolean(params.setupCode),
devicePairSetupLoading: false,
devicePairSetupError: null,
devicePairSetup: params.setupCode
? {
setupCode: params.setupCode,
gatewayUrl: "wss://gateway.example.test",
auth: "token",
urlSource: "test",
}
: null,
devicePairSetupAccess: "full",
devicePairPendingCount: 0,
updateAvailable: null,
updateRunning: false,
updateStatusBanner: null,
controlUiRefreshRequired: false,
},
snapshot: overlaySnapshot,
openDevicePairSetup,
},
config: { current: {} },
@@ -81,7 +89,13 @@ function createPairingShell(params: {
theme: { mode: "system" },
} as unknown as ApplicationContext;
const shell = document.createElement("openclaw-app-shell") as PairingShell;
shell.runtime = { context, router: {} } as ApplicationRuntime;
shell.runtime = {
context,
router: {
getState: () => ({ status: "idle", matches: [], pendingMatches: [] }),
subscribeSelector: () => () => undefined,
},
} as unknown as ApplicationRuntime;
const container = document.createElement("div");
const renderSidebar = () => {
@@ -93,11 +107,13 @@ function createPairingShell(params: {
return sidebar;
};
return { snapshot, openDevicePairSetup, renderSidebar, container };
return { snapshot, overlaySnapshot, openDevicePairSetup, renderSidebar, container };
}
afterEach(() => {
afterEach(async () => {
vi.useRealTimers();
document.body.replaceChildren();
await Promise.resolve();
vi.unstubAllGlobals();
vi.restoreAllMocks();
Reflect.deleteProperty(document, "execCommand");
@@ -161,12 +177,12 @@ describe("application shell pairing access", () => {
auth: { role: "operator", scopes: ["operator.pairing"] },
setupCode: "pair-mobile-secret",
});
document.body.append(container);
renderSidebar();
const pairing = container.querySelector<HTMLElement>(".device-pair-setup");
if (!pairing) {
throw new Error("Expected the application shell to render its mobile pairing dialog");
}
document.body.append(pairing);
await vi.waitFor(() =>
expect(container.querySelector<HTMLElement>(".device-pair-setup")).not.toBeNull(),
);
const pairing = container.querySelector<HTMLElement>(".device-pair-setup")!;
const button = pairing.querySelector<HTMLButtonElement>(".device-pair-setup__actions button");
button?.click();
@@ -186,4 +202,31 @@ describe("application shell pairing access", () => {
expect(button?.textContent?.trim()).toBe("Copy setup code");
expect(button?.getAttribute("aria-label")).toBe("Copy setup code");
});
it("expires a node setup link from the pairing clock, independently of approvals", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(4_000);
const { overlaySnapshot, container, renderSidebar } = createPairingShell({
auth: { role: "operator", scopes: ["operator.pairing"] },
setupCode: "pair-node-secret",
expiresAtMs: 5_000,
approvalNowMs: 50_000,
});
document.body.append(container);
overlaySnapshot.devicePairSetupAccess = "node";
renderSidebar();
await vi.waitFor(() =>
expect(container.querySelector('[role="timer"]')?.textContent).toContain("0:01"),
);
expect(container.querySelector(".device-pair-setup__command code")).not.toBeNull();
now.mockReturnValue(5_000);
renderSidebar();
await vi.waitFor(() =>
expect(container.querySelector('[role="timer"]')?.textContent?.toLowerCase()).toContain(
"expired",
),
);
expect(container.querySelector(".device-pair-setup__command code")).toBeNull();
});
});
+5
View File
@@ -65,6 +65,7 @@ import {
COMMAND_PALETTE_ELEMENT,
CUSTODIAN_PANEL_ELEMENT,
DESKTOP_PANEL_ELEMENT,
DEVICE_PAIR_SETUP_ELEMENT,
EXEC_APPROVAL_ELEMENT,
preloadOptionalElement,
TERMINAL_PANEL_ELEMENT,
@@ -135,6 +136,7 @@ class OpenClawShell
readonly browserPanelElement = BROWSER_PANEL_ELEMENT;
readonly desktopPanelElement = DESKTOP_PANEL_ELEMENT;
readonly custodianPanelElement = CUSTODIAN_PANEL_ELEMENT;
readonly devicePairSetupElement = DEVICE_PAIR_SETUP_ELEMENT;
readonly execApprovalElement = EXEC_APPROVAL_ELEMENT;
@query("openclaw-command-palette") commandPalette: CommandPaletteElement | undefined;
@query("openclaw-exec-approval")
@@ -545,6 +547,9 @@ class OpenClawShell
if ((context.overlays?.snapshot.approvalQueue.length ?? 0) > 0) {
preloadOptionalElement(this, this.execApprovalElement);
}
if (context.overlays?.snapshot.devicePairSetupOpen) {
preloadOptionalElement(this, this.devicePairSetupElement);
}
const navState = {
collapsed: this.nativeNavCollapsed(),
width: context.navigation.snapshot.navWidth,
+27 -20
View File
@@ -16,7 +16,6 @@ import { findUiSessionRow } from "../lib/sessions/route-navigation.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts";
import { renderDevicePairSetup } from "../pages/devices/view-pairing.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import { pluginTabKey, pluginTabRefFromSearch } from "../pages/plugin/route.ts";
import type { ShellRouteState } from "./app-host-route-state.ts";
@@ -54,6 +53,7 @@ export interface ShellViewHost {
readonly commandPaletteElement: OptionalCustomElement;
readonly custodianMinimizeRequestId: number;
readonly desktopNavigationExpanded: boolean;
readonly devicePairSetupElement: OptionalCustomElement;
readonly execApprovalElement: OptionalCustomElement;
readonly nativeHistoryState: NativeHistoryState;
readonly navDrawerOpen: boolean;
@@ -544,25 +544,32 @@ export function renderApplicationShell(host: ShellViewHost) {
}}
></openclaw-exec-approval>`
: nothing}
${renderDevicePairSetup({
open: overlaySnapshot.devicePairSetupOpen,
loading: overlaySnapshot.devicePairSetupLoading,
error: overlaySnapshot.devicePairSetupError,
setup: overlaySnapshot.devicePairSetup,
access: overlaySnapshot.devicePairSetupAccess,
pendingCount: overlaySnapshot.devicePairPendingCount,
onRefresh: () => void context.overlays.refreshDevicePairSetup(),
onAccessChange: (access) => void context.overlays.setDevicePairSetupAccess(access),
onClose: () => context.overlays.closeDevicePairSetup(),
onManageDevices: () => {
context.overlays.closeDevicePairSetup();
host.navigate("devices");
},
onGetApps: () => {
context.overlays.closeDevicePairSetup();
host.navigate("apps");
},
})}
${isOptionalElementDefined(host.devicePairSetupElement)
? html`<openclaw-device-pair-setup
.props=${{
open: overlaySnapshot.devicePairSetupOpen,
loading: overlaySnapshot.devicePairSetupLoading,
error: overlaySnapshot.devicePairSetupError,
setup: overlaySnapshot.devicePairSetup,
access: overlaySnapshot.devicePairSetupAccess,
nowMs: Date.now(),
pendingCount: overlaySnapshot.devicePairPendingCount,
onRefresh: () => void context.overlays.refreshDevicePairSetup(),
onAccessChange: (
access: Parameters<typeof context.overlays.setDevicePairSetupAccess>[0],
) => void context.overlays.setDevicePairSetupAccess(access),
onClose: () => context.overlays.closeDevicePairSetup(),
onManageDevices: () => {
context.overlays.closeDevicePairSetup();
host.navigate("devices");
},
onGetApps: () => {
context.overlays.closeDevicePairSetup();
host.navigate("apps");
},
}}
></openclaw-device-pair-setup>`
: nothing}
${onboarding && activeRoute !== "custodian"
? html`<openclaw-onboarding-memory-import
.active=${true}
+8
View File
@@ -87,6 +87,14 @@ export const EXEC_APPROVAL_ELEMENT = {
loadModule: () => import("../components/exec-approval.ts"),
} satisfies OptionalCustomElement;
const DEVICE_PAIR_SETUP_TAG = "openclaw-device-pair-setup";
export const DEVICE_PAIR_SETUP_ELEMENT = {
tagName: DEVICE_PAIR_SETUP_TAG,
label: DEVICE_PAIR_SETUP_TAG,
loadModule: () => import("../pages/devices/view-pairing.ts"),
} satisfies OptionalCustomElement;
const hostElementLoads = new WeakMap<UpdatingHost, Map<string, Promise<void>>>();
export function isOptionalElementDefined(element: OptionalCustomElement): boolean {
+41
View File
@@ -0,0 +1,41 @@
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import type { DevicePairSetup, DevicePairSetupAccess } from "../lib/device-pair-setup.ts";
import type { DeviceAuthMigrationSnapshot } from "./device-auth-migration.ts";
import type { ExecApprovalDecision, ExecApprovalRequest } from "./exec-approval.ts";
import type { ApplicationStatusBanner } from "./update-overlay-helpers.ts";
export type ApplicationOverlaySnapshot = {
updateAvailable: UpdateAvailable | null;
updateSchedule: UpdateScheduleState | null;
heldUpdateCampaignId: string | null;
updateRunning: boolean;
updateReconciliationPending: boolean;
updateStatusBanner: ApplicationStatusBanner | null;
controlUiRefreshRequired: boolean;
approvalQueue: readonly ExecApprovalRequest[];
approvalBusy: boolean;
approvalErrors: ReadonlyMap<string, string>;
approvalNowMs: number;
devicePairSetupOpen: boolean;
devicePairSetupLoading: boolean;
devicePairSetupError: string | null;
devicePairSetup: DevicePairSetup | null;
devicePairSetupAccess: DevicePairSetupAccess;
devicePairPendingCount: number;
deviceAuthMigration: DeviceAuthMigrationSnapshot;
};
export type ApplicationOverlays = {
readonly snapshot: ApplicationOverlaySnapshot;
subscribe: (listener: (snapshot: ApplicationOverlaySnapshot) => void) => () => void;
refreshUpdateStatus: () => Promise<void>;
runUpdate: () => Promise<void>;
holdUpdate: () => Promise<boolean>;
decideApproval: (decision: ExecApprovalDecision, approvalId?: string) => Promise<void>;
openDevicePairSetup: () => Promise<void>;
refreshDevicePairSetup: () => Promise<void>;
setDevicePairSetupAccess: (access: DevicePairSetupAccess) => Promise<void>;
closeDevicePairSetup: () => void;
secureThisBrowser: () => Promise<void>;
dispose: () => void;
};
+5 -41
View File
@@ -3,7 +3,7 @@ import {
type GatewayUpdateAvailableEventPayload,
} from "../../../src/gateway/events.js";
import type { GatewayEventFrame } from "../api/gateway.ts";
import type { UpdateAvailable, UpdateHoldResult, UpdateScheduleState } from "../api/types.ts";
import type { UpdateHoldResult, UpdateScheduleState } from "../api/types.ts";
import { controlUiVersionDiffersFrom } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import {
@@ -13,8 +13,7 @@ import {
readDevicePairSetupSnapshot,
refreshDevicePairSetup as refreshDevicePairSetupState,
setDevicePairSetupAccess as setPairAccess,
type DevicePairSetup,
type DevicePairSetupAccess,
syncDevicePairSetupCountdown,
} from "../lib/device-pair-setup.ts";
import {
createDeviceAuthMigrationLoader,
@@ -28,12 +27,12 @@ import {
parseApprovalRequestedEvent,
parseExecApprovalResolved,
resolveApprovalRequest,
type ExecApprovalDecision,
type ExecApprovalPromptState,
type ExecApprovalRequest,
} from "./exec-approval.ts";
import type { ApplicationGateway } from "./gateway.ts";
import { readGatewayOperatorAccess } from "./operator-access.ts";
import type { ApplicationOverlays, ApplicationOverlaySnapshot } from "./overlays-types.ts";
export type { ApplicationOverlays } from "./overlays-types.ts";
import {
createOverlayApprovalRefresher,
createOverlayPairingPendingCount,
@@ -65,42 +64,6 @@ import {
announceVerifiedUpdateInstall,
} from "./update-success-notice.ts";
type ApplicationOverlaySnapshot = {
updateAvailable: UpdateAvailable | null;
updateSchedule: UpdateScheduleState | null;
heldUpdateCampaignId: string | null;
updateRunning: boolean;
updateReconciliationPending: boolean;
updateStatusBanner: ApplicationStatusBanner | null;
controlUiRefreshRequired: boolean;
approvalQueue: readonly ExecApprovalRequest[];
approvalBusy: boolean;
approvalErrors: ReadonlyMap<string, string>;
approvalNowMs: number;
devicePairSetupOpen: boolean;
devicePairSetupLoading: boolean;
devicePairSetupError: string | null;
devicePairSetup: DevicePairSetup | null;
devicePairSetupAccess: DevicePairSetupAccess;
devicePairPendingCount: number;
deviceAuthMigration: import("./device-auth-migration.ts").DeviceAuthMigrationSnapshot;
};
export type ApplicationOverlays = {
readonly snapshot: ApplicationOverlaySnapshot;
subscribe: (listener: (snapshot: ApplicationOverlaySnapshot) => void) => () => void;
refreshUpdateStatus: () => Promise<void>;
runUpdate: () => Promise<void>;
holdUpdate: () => Promise<boolean>;
decideApproval: (decision: ExecApprovalDecision, approvalId?: string) => Promise<void>;
openDevicePairSetup: () => Promise<void>;
refreshDevicePairSetup: () => Promise<void>;
setDevicePairSetupAccess: (access: DevicePairSetupAccess) => Promise<void>;
closeDevicePairSetup: () => void;
secureThisBrowser: () => Promise<void>;
dispose: () => void;
};
function isGatewayEvent(value: unknown): value is GatewayEventFrame {
return Boolean(value && typeof value === "object" && "event" in value);
}
@@ -192,6 +155,7 @@ export function createApplicationOverlays(
publish();
await operation;
if (!disposed) {
syncDevicePairSetupCountdown(devicePairSetupState, publish);
publish();
}
};
+4 -9
View File
@@ -1,6 +1,7 @@
import type { GatewayBrowserClient, GatewayHelloOk } from "../api/gateway.ts";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import { t } from "../i18n/index.ts";
import { formatCountdown } from "../lib/format.ts";
import { readUpdateAvailableValue, readUpdateScheduleValue } from "./update-schedule-dto.ts";
export type ApplicationStatusBanner = {
@@ -453,12 +454,6 @@ export function projectUpdateStatusResponse(
};
}
function formatUpdateCountdown(deadlineMs: number, nowMs = Date.now()): string {
const totalSeconds = Math.max(0, Math.ceil((deadlineMs - nowMs) / 1_000));
const minutes = Math.floor(totalSeconds / 60);
return `${minutes}:${String(totalSeconds % 60).padStart(2, "0")}`;
}
export function formatUpdateCampaignLabel(
schedule: UpdateScheduleState | null | undefined,
nowMs = Date.now(),
@@ -469,7 +464,7 @@ export function formatUpdateCampaignLabel(
}
if (campaign.holdUntilMs !== undefined && campaign.holdUntilMs > nowMs) {
return t("updates.campaign.held", {
time: formatUpdateCountdown(campaign.holdUntilMs, nowMs),
time: formatCountdown(campaign.holdUntilMs, nowMs),
});
}
if (campaign.state === "applying") {
@@ -477,11 +472,11 @@ export function formatUpdateCampaignLabel(
}
if (campaign.state === "waiting-for-idle") {
return t("updates.campaign.waitingForIdle", {
time: formatUpdateCountdown(campaign.forceAtMs, nowMs),
time: formatCountdown(campaign.forceAtMs, nowMs),
});
}
return t("updates.campaign.countdown", {
time: formatUpdateCountdown(campaign.applyAtMs ?? campaign.forceAtMs, nowMs),
time: formatCountdown(campaign.applyAtMs ?? campaign.forceAtMs, nowMs),
});
}
+2 -6
View File
@@ -7,6 +7,7 @@ import type {
ExecApprovalRequestPayload,
} from "../app/exec-approval.ts";
import { t } from "../i18n/index.ts";
import { formatCountdown } from "../lib/format.ts";
const DEFAULT_EXEC_APPROVAL_DECISIONS = [
"allow-once",
@@ -24,14 +25,9 @@ type ExecApprovalCardProps = {
onDecision: (approvalId: string, decision: ExecApprovalDecision) => void | Promise<void>;
};
export function formatApprovalCountdown(expiresAtMs: number, nowMs: number): string {
const totalSeconds = Math.max(0, Math.ceil((expiresAtMs - nowMs) / 1_000));
return `${String(Math.floor(totalSeconds / 60)).padStart(2, "0")}:${String(totalSeconds % 60).padStart(2, "0")}`;
}
export function approvalRemainingLabel(expiresAtMs: number, nowMs: number): string {
return expiresAtMs > nowMs
? t("execApproval.expiresIn", { time: formatApprovalCountdown(expiresAtMs, nowMs) })
? t("execApproval.expiresIn", { time: formatCountdown(expiresAtMs, nowMs, true) })
: t("execApproval.expired");
}
+2 -2
View File
@@ -5,12 +5,12 @@ import { property, query, state } from "lit/decorators.js";
import { modalApprovalQueue } from "../app/approval-presentation.ts";
import type { ExecApprovalDecision, ExecApprovalRequest } from "../app/exec-approval.ts";
import { t } from "../i18n/index.ts";
import { formatCountdown } from "../lib/format.ts";
import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import {
approvalRemainingLabel,
approvalTitle,
formatApprovalCountdown,
renderExecApprovalCard,
resolveApprovalDecisions,
} from "./exec-approval-card.ts";
@@ -47,7 +47,7 @@ function renderApprovalQueueList(params: {
${others.map((entry) => {
const command = compactCommand(entry.request.command);
const agent = entry.request.agentId?.trim() || "—";
const countdown = formatApprovalCountdown(entry.expiresAtMs, params.nowMs);
const countdown = formatCountdown(entry.expiresAtMs, params.nowMs, true);
return html`
<button
class="exec-approval-list__item"
+31 -3
View File
@@ -68,7 +68,7 @@ suite.define(() => {
await gateway.deferNext("device.pair.list");
await sidebarPairingButton.click();
const dialog = page.getByRole("dialog", { name: "OpenClaw mobile" });
const dialog = page.getByRole("dialog", { name: "Pair a device" });
const qr = page.getByAltText("OpenClaw mobile pairing QR code");
await dialog.waitFor();
expect(await dialog.isVisible()).toBe(true);
@@ -84,7 +84,7 @@ suite.define(() => {
// modal-dialog renders its content in light DOM outside the native dialog element.
const accessRadios = page.locator('input[name="device-pair-access"]');
await expect.poll(async () => accessRadios.count()).toBe(2);
await expect.poll(async () => accessRadios.count()).toBe(3);
const fullAccess = accessRadios.nth(0);
const limitedAccess = accessRadios.nth(1);
expect(await fullAccess.isChecked()).toBe(true);
@@ -136,7 +136,7 @@ suite.define(() => {
expect(settingsResponse?.status()).toBe(200);
const quickSettingsPairingButton = page
.locator(".security-page")
.getByRole("button", { name: "Pair mobile device" });
.getByRole("button", { name: "Pair device" });
await quickSettingsPairingButton.waitFor();
const setupRequestsBeforeQuickSettings = (
await gateway.getRequests("device.pair.setupCode")
@@ -191,6 +191,34 @@ suite.define(() => {
bootstrapProfile: "limited",
});
await page.locator(".device-pair-setup__close").click();
await dialog.waitFor({ state: "hidden" });
await gateway.setMethodResponse("device.pair.setupCode", {
access: "node",
auth: "token",
expiresAtMs: Date.now() + 60_000,
gatewayUrl: "wss://gateway.example.test",
setupCode: "Node_AbC123",
urlSource: "test",
});
await quickSettingsPairingButton.click();
await dialog.waitFor();
const nodeAccess = page.locator('input[name="device-pair-access"]').nth(2);
await nodeAccess.check();
await page.getByRole("button", { name: "Create setup code" }).click();
await expect
.poll(async () => (await gateway.getRequests("device.pair.setupCode")).length)
.toBe(setupRequestsBeforeQuickSettings + 3);
expect((await gateway.getRequests("device.pair.setupCode")).at(-1)?.params).toEqual({
bootstrapProfile: "node",
includeQr: false,
});
const nodeCommand = page.getByText('openclaw node run --pair "oc-pair://Node_AbC123"', {
exact: true,
});
await nodeCommand.waitFor();
expect(await nodeCommand.isVisible()).toBe(true);
await page.getByRole("button", { name: "Manage devices" }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/devices");
expect(pageErrors).toEqual([]);
+9 -4
View File
@@ -504,20 +504,22 @@ export const en: TranslationMap = {
},
devices: {
pairing: {
button: "Pair mobile device",
button: "Pair device",
adminRequired: "Administrator access is required to create setup codes.",
title: "OpenClaw mobile",
subtitle: "Scan this QR code in the mobile app to connect a new phone.",
title: "Pair a device",
subtitle: "Create a secure setup for a mobile app or node host.",
noApp: "Don't have the app yet?",
getApps: "Get the apps",
generating: "Creating a secure setup code…",
accessTitle: "Mobile access",
accessTitle: "Setup type",
fullAccess: "Full access (recommended)",
fullAccessHint:
"Device capabilities plus complete Gateway controls, including settings and upgrades.",
limitedAccess: "Limited access",
limitedAccessHint:
"Device capabilities, chat, and approvals without administrative controls.",
nodeAccess: "Node host",
nodeAccessHint: "Connect a computer as a command and capability host.",
generateCode: "Create setup code",
transportLimitedTitle: "Limited for network safety",
transportLimitedHint:
@@ -526,11 +528,14 @@ export const en: TranslationMap = {
qrAlt: "OpenClaw mobile pairing QR code",
qrUnavailable: "QR unavailable. Copy the setup code instead.",
copySetupCode: "Copy setup code",
nodeExpiresIn: "This setup link expires in {time}.",
nodeExpired: "This setup link has expired. Create a new one.",
newCode: "New code",
showSetupCode: "Show setup code",
pending: "Device requests waiting for review: {count}",
review: "Review",
waiting: "Official OpenClaw mobile apps connect automatically after scanning.",
nodeWaiting: "Run the command on the device, then review its pairing request here.",
help: "Pairing help",
manageDevices: "Manage devices",
},
+17 -1
View File
@@ -21,7 +21,7 @@ function deferred<T>() {
function setupResult(
setupCode: string,
access?: "full" | "limited",
access?: "full" | "limited" | "node",
accessDowngraded?: boolean,
): DevicePairSetup {
return {
@@ -143,6 +143,22 @@ describe("device pairing setup state", () => {
expect(state.devicePairSetup?.setupCode).toBe("LIMITED");
});
it("requests the node bootstrap profile when selected", async () => {
const request = vi.fn().mockResolvedValue(setupResult("NODE", "node"));
const state = stateWithClient({
request,
} as unknown as DevicePairSetupState["client"]);
await setDevicePairSetupAccess(state, "node");
await refreshDevicePairSetup(state);
expect(request).toHaveBeenCalledWith("device.pair.setupCode", {
bootstrapProfile: "node",
includeQr: false,
});
expect(state.devicePairSetupAccess).toBe("node");
});
it("reflects a server-side plaintext downgrade", async () => {
const request = vi.fn().mockResolvedValue(setupResult("LIMITED", "limited", true));
const state = stateWithClient({
+36 -3
View File
@@ -6,7 +6,7 @@ type GatewayRequestClient = {
};
export type DevicePairSetup = DevicePairSetupCodeResult;
export type DevicePairSetupAccess = "full" | "limited";
export type DevicePairSetupAccess = "full" | "limited" | "node";
type DevicePairSetupState = {
client: GatewayRequestClient | null;
@@ -16,6 +16,7 @@ type DevicePairSetupState = {
devicePairSetupError: string | null;
devicePairSetup: DevicePairSetup | null;
devicePairSetupAccess: DevicePairSetupAccess;
devicePairSetupTimer: ReturnType<typeof setInterval> | null;
};
type DevicePairSetupOverlayState = DevicePairSetupState & { pendingCount: number };
@@ -31,6 +32,7 @@ export function createDevicePairSetupState(params: {
devicePairSetupError: null,
devicePairSetup: null,
devicePairSetupAccess: "full",
devicePairSetupTimer: null,
pendingCount: 0,
};
}
@@ -46,6 +48,32 @@ export function readDevicePairSetupSnapshot(state: DevicePairSetupOverlayState)
};
}
function stopDevicePairSetupCountdown(state: DevicePairSetupState) {
if (state.devicePairSetupTimer) {
clearInterval(state.devicePairSetupTimer);
state.devicePairSetupTimer = null;
}
}
export function syncDevicePairSetupCountdown(state: DevicePairSetupState, onTick: () => void) {
stopDevicePairSetupCountdown(state);
const expiresAtMs = state.devicePairSetup?.expiresAtMs;
if (
state.devicePairSetupAccess !== "node" ||
!state.devicePairSetupOpen ||
typeof expiresAtMs !== "number" ||
expiresAtMs <= Date.now()
) {
return;
}
state.devicePairSetupTimer = setInterval(() => {
if (!state.devicePairSetupOpen || expiresAtMs <= Date.now()) {
stopDevicePairSetupCountdown(state);
}
onTick();
}, 1_000);
}
const devicePairSetupRequests = new WeakMap<DevicePairSetupState, object>();
export async function openDevicePairSetup(state: DevicePairSetupState) {
@@ -64,7 +92,11 @@ export async function refreshDevicePairSetup(state: DevicePairSetupState) {
try {
const result = await client.request<DevicePairSetup>(
"device.pair.setupCode",
state.devicePairSetupAccess === "limited" ? { bootstrapProfile: "limited" } : {},
state.devicePairSetupAccess === "full"
? {}
: state.devicePairSetupAccess === "node"
? { bootstrapProfile: "node", includeQr: false }
: { bootstrapProfile: "limited" },
);
if (
devicePairSetupRequests.get(state) !== requestToken ||
@@ -74,7 +106,7 @@ export async function refreshDevicePairSetup(state: DevicePairSetupState) {
) {
return;
}
if (result.access === "full" || result.access === "limited") {
if (result.access === "full" || result.access === "limited" || result.access === "node") {
state.devicePairSetupAccess = result.access;
}
state.devicePairSetup = result;
@@ -113,6 +145,7 @@ export async function setDevicePairSetupAccess(
}
export function closeDevicePairSetup(state: DevicePairSetupState) {
stopDevicePairSetupCountdown(state);
devicePairSetupRequests.delete(state);
state.devicePairSetupOpen = false;
state.devicePairSetupLoading = false;
+6
View File
@@ -10,6 +10,12 @@ import { i18n, t } from "../i18n/index.ts";
export { formatByteSize } from "@openclaw/normalization-core";
export function formatCountdown(deadlineMs: number, nowMs: number, padMinutes = false): string {
const totalSeconds = Math.max(0, Math.ceil((deadlineMs - nowMs) / 1_000));
const minutes = String(Math.floor(totalSeconds / 60));
return `${padMinutes ? minutes.padStart(2, "0") : minutes}:${String(totalSeconds % 60).padStart(2, "0")}`;
}
type FormatTimeAgoOptions = {
suffix?: boolean;
fallback?: string;
@@ -4,6 +4,7 @@ import { property, state } from "lit/decorators.js";
import type { QuestionPrompt } from "../../../app/question-prompt.ts";
import { icons } from "../../../components/icons.ts";
import { t } from "../../../i18n/index.ts";
import { formatCountdown } from "../../../lib/format.ts";
type QuestionPanelQuestion = {
questionId: string;
@@ -50,12 +51,6 @@ type GatewayQuestionPanelOptions = {
onNextRequest?: () => void;
};
function formatRemaining(expiresAtMs: number, nowMs: number): string {
const seconds = Math.max(0, Math.ceil((expiresAtMs - nowMs) / 1_000));
const minutes = Math.floor(seconds / 60);
return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
}
function promptDraftAnswers(prompt: QuestionPrompt): Record<string, string[]> {
return Object.fromEntries(
prompt.questions.map((question) => {
@@ -93,7 +88,7 @@ export function createGatewayQuestionPanelProps(
submitting: prompt.submitting,
countdown:
prompt.status === "pending"
? formatRemaining(prompt.expiresAtMs, options.nowMs)
? formatCountdown(prompt.expiresAtMs, options.nowMs)
: undefined,
answersById: promptDraftAnswers(prompt),
error: prompt.error,
+2 -2
View File
@@ -154,8 +154,8 @@ describe("renderSecurity", () => {
render(renderSecurity(createProps({ onPairMobile })), container);
expectRowByTitle(container, "OpenClaw mobile");
const button = expectButtonByText(container, "Pair mobile device");
expectRowByTitle(container, "Pair a device");
const button = expectButtonByText(container, "Pair device");
expect(button.disabled).toBe(false);
button.click();
expect(onPairMobile).toHaveBeenCalledOnce();
+83
View File
@@ -0,0 +1,83 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderDevicePairSetup } from "./view-pairing.ts";
describe("device pairing dialog", () => {
it.each([
{
access: "full" as const,
href: "https://docs.openclaw.ai/channels/pairing#pair-from-the-control-ui-recommended",
},
{
access: "limited" as const,
href: "https://docs.openclaw.ai/channels/pairing#pair-from-the-control-ui-recommended",
},
{
access: "node" as const,
href: "https://docs.openclaw.ai/gateway/pairing#one-paste-node-pairing",
},
])("links $access setup help to the matching workflow", ({ access, href }) => {
const container = document.createElement("div");
render(
renderDevicePairSetup({
open: true,
loading: false,
error: null,
setup: null,
access,
nowMs: 0,
pendingCount: 0,
onRefresh: vi.fn(),
onAccessChange: vi.fn(),
onClose: vi.fn(),
onManageDevices: vi.fn(),
onGetApps: vi.fn(),
}),
container,
);
expect(container.querySelector<HTMLAnchorElement>(".device-pair-setup__footer a")?.href).toBe(
href,
);
});
it("renders the node one-paste command and quiet expiry countdown", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderDevicePairSetup({
open: true,
loading: false,
error: null,
setup: {
setupCode: "AbC_123",
gatewayUrl: "wss://gateway.example",
auth: "token",
urlSource: "test",
access: "node",
expiresAtMs: 70_000,
},
access: "node",
nowMs: 10_000,
pendingCount: 0,
onRefresh: vi.fn(),
onAccessChange: vi.fn(),
onClose: vi.fn(),
onManageDevices: vi.fn(),
onGetApps: vi.fn(),
}),
container,
);
expect(container.querySelectorAll('input[name="device-pair-access"]')).toHaveLength(3);
expect(container.querySelector(".device-pair-setup__command code")?.textContent).toBe(
'openclaw node run --pair "oc-pair://AbC_123"',
);
expect(container.querySelector('[role="timer"]')?.textContent?.trim()).toBe(
"This setup link expires in 1:00.",
);
});
});
+98 -56
View File
@@ -1,20 +1,30 @@
// Devices page renders the mobile device pairing setup dialog.
import { html, nothing } from "lit";
import { handleCopyButton } from "../../components/copy-button.ts";
import { property } from "lit/decorators.js";
import { handleCopyButton, renderCopyButton } from "../../components/copy-button.ts";
import { icons } from "../../components/icons.ts";
import "../../components/modal-dialog.ts";
import { t } from "../../i18n/index.ts";
import type { DevicePairSetup, DevicePairSetupAccess } from "../../lib/device-pair-setup.ts";
import { formatCountdown } from "../../lib/format.ts";
import { OpenClawLightDomContentsElement } from "../../lit/openclaw-element.ts";
const PAIRING_DOCS_URL =
const MOBILE_PAIRING_DOCS_URL =
"https://docs.openclaw.ai/channels/pairing#pair-from-the-control-ui-recommended";
const NODE_PAIRING_DOCS_URL = "https://docs.openclaw.ai/gateway/pairing#one-paste-node-pairing";
const PAIRING_ACCESS_OPTIONS = [
["full", "devices.pairing.fullAccess", "devices.pairing.fullAccessHint"],
["limited", "devices.pairing.limitedAccess", "devices.pairing.limitedAccessHint"],
["node", "devices.pairing.nodeAccess", "devices.pairing.nodeAccessHint"],
] as const satisfies ReadonlyArray<readonly [DevicePairSetupAccess, string, string]>;
type DevicePairSetupProps = {
export type DevicePairSetupProps = {
open: boolean;
loading: boolean;
error: string | null;
setup: DevicePairSetup | null;
access: DevicePairSetupAccess;
nowMs: number;
pendingCount: number;
onRefresh: () => void;
onAccessChange: (access: DevicePairSetupAccess) => void;
@@ -23,6 +33,14 @@ type DevicePairSetupProps = {
onGetApps: () => void;
};
export class OpenClawDevicePairSetup extends OpenClawLightDomContentsElement {
@property({ attribute: false }) props: DevicePairSetupProps | null = null;
override render() {
return this.props ? renderDevicePairSetup(this.props) : nothing;
}
}
export function renderDevicePairSetup(props: DevicePairSetupProps) {
if (!props.open) {
return nothing;
@@ -33,21 +51,29 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
const setup = props.setup;
const pendingCount = props.pendingCount;
const gatewayUrls = setup?.gatewayUrls ?? (setup ? [setup.gatewayUrl] : []);
const isNodeSetup = props.access === "node";
const pairingDocsUrl = isNodeSetup ? NODE_PAIRING_DOCS_URL : MOBILE_PAIRING_DOCS_URL;
const nodeCommand = setup ? `openclaw node run --pair "oc-pair://${setup.setupCode}"` : "";
const setupExpired = typeof setup?.expiresAtMs === "number" && setup.expiresAtMs <= props.nowMs;
return html`
<openclaw-modal-dialog label=${title} description=${description} @modal-cancel=${props.onClose}>
<section class="device-pair-setup">
<header class="device-pair-setup__header">
<div class="device-pair-setup__phone" aria-hidden="true">${icons.smartphone}</div>
<div class="device-pair-setup__phone" aria-hidden="true">
${isNodeSetup ? icons.server : icons.smartphone}
</div>
<div>
<h2>${title}</h2>
<p>${description}</p>
<p class="device-pair-setup__get-apps">
${t("devices.pairing.noApp")}
<button type="button" @click=${props.onGetApps}>
${t("devices.pairing.getApps")}
</button>
</p>
${isNodeSetup
? nothing
: html`<p class="device-pair-setup__get-apps">
${t("devices.pairing.noApp")}
<button type="button" @click=${props.onGetApps}>
${t("devices.pairing.getApps")}
</button>
</p>`}
</div>
<button
class="btn btn--icon btn--ghost device-pair-setup__close"
@@ -62,30 +88,20 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
<div class="device-pair-setup__body">
<fieldset class="device-pair-setup__access" ?disabled=${props.loading || setup !== null}>
<legend>${t("devices.pairing.accessTitle")}</legend>
<label>
<input
type="radio"
name="device-pair-access"
.checked=${props.access === "full"}
@change=${() => props.onAccessChange("full")}
/>
<span>
<strong>${t("devices.pairing.fullAccess")}</strong>
<small>${t("devices.pairing.fullAccessHint")}</small>
</span>
</label>
<label>
<input
type="radio"
name="device-pair-access"
.checked=${props.access === "limited"}
@change=${() => props.onAccessChange("limited")}
/>
<span>
<strong>${t("devices.pairing.limitedAccess")}</strong>
<small>${t("devices.pairing.limitedAccessHint")}</small>
</span>
</label>
${PAIRING_ACCESS_OPTIONS.map(
([access, label, hint]) => html`<label>
<input
type="radio"
name="device-pair-access"
.checked=${props.access === access}
@change=${() => props.onAccessChange(access)}
/>
<span>
<strong>${t(label)}</strong>
<small>${t(hint)}</small>
</span>
</label>`,
)}
</fieldset>
${!setup && !props.loading && !props.error
? html`
@@ -120,18 +136,36 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
: nothing}
${setup
? html`
<div class="device-pair-setup__qr-frame">
${setup.qrDataUrl
? html`<img
class="device-pair-setup__qr"
src=${setup.qrDataUrl}
alt=${t("devices.pairing.qrAlt")}
draggable="false"
/>`
: html`<div class="device-pair-setup__qr-unavailable">
${t("devices.pairing.qrUnavailable")}
</div>`}
</div>
${isNodeSetup
? html`<div class="device-pair-setup__command">
${setupExpired
? nothing
: html`<div class="login-gate__command">
<code>${nodeCommand}</code>
${renderCopyButton(nodeCommand, t("connection.help.copyCommand"))}
</div>`}
${setup.expiresAtMs
? html`<p class="device-pair-setup__waiting" role="timer" aria-live="off">
${setupExpired
? t("devices.pairing.nodeExpired")
: t("devices.pairing.nodeExpiresIn", {
time: formatCountdown(setup.expiresAtMs, props.nowMs),
})}
</p>`
: nothing}
</div>`
: html`<div class="device-pair-setup__qr-frame">
${setup.qrDataUrl
? html`<img
class="device-pair-setup__qr"
src=${setup.qrDataUrl}
alt=${t("devices.pairing.qrAlt")}
draggable="false"
/>`
: html`<div class="device-pair-setup__qr-unavailable">
${t("devices.pairing.qrUnavailable")}
</div>`}
</div>`}
<div class="device-pair-setup__meta">
<span class="settings-status settings-status--accent">
@@ -159,14 +193,16 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
: nothing}
<div class="device-pair-setup__actions">
<button
class="btn primary"
type="button"
@click=${(event: Event) =>
void handleCopyButton(event, setup.setupCode, copyLabel)}
>
${icons.copy} <span data-copy-label>${copyLabel}</span>
</button>
${isNodeSetup
? nothing
: html`<button
class="btn primary"
type="button"
@click=${(event: Event) =>
void handleCopyButton(event, setup.setupCode, copyLabel)}
>
${icons.copy} <span data-copy-label>${copyLabel}</span>
</button>`}
<button
class="btn"
type="button"
@@ -194,13 +230,15 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
</button>
</div>
`
: html`<p class="device-pair-setup__waiting">${t("devices.pairing.waiting")}</p>`}
: html`<p class="device-pair-setup__waiting">
${t(isNodeSetup ? "devices.pairing.nodeWaiting" : "devices.pairing.waiting")}
</p>`}
`
: nothing}
</div>
<footer class="device-pair-setup__footer">
<a href=${PAIRING_DOCS_URL} target="_blank" rel="noreferrer">
<a href=${pairingDocsUrl} target="_blank" rel="noreferrer">
${t("devices.pairing.help")}
</a>
<button class="btn btn--ghost" type="button" @click=${props.onManageDevices}>
@@ -211,3 +249,7 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
</openclaw-modal-dialog>
`;
}
if (!customElements.get("openclaw-device-pair-setup")) {
customElements.define("openclaw-device-pair-setup", OpenClawDevicePairSetup);
}
+6
View File
@@ -5278,6 +5278,12 @@ td.data-table-key-col {
text-align: center;
}
.device-pair-setup__command {
display: grid;
width: min(540px, 100%);
gap: 10px;
}
.device-pair-setup__meta {
display: flex;
max-width: 100%;