mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
3b01ea7905
commit
d44f70eb4b
+192
-192
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()
|
||||
|
||||
+31
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
{"contentHash":"ca227941dce03110d71765ad1d2e8df89e30d09c19f664082d4e2a113937211b","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"dab3cc4ad5d01284c3458191168aad2eb829731334b5c2bc58909eed9df05a7f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user