mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(mobile): default pairing to full node access (#105928)
* feat(mobile): default pairing to full node access * chore: leave release notes to release workflow * refactor(mobile): bound pairing helpers * chore(i18n): refresh mobile access inventory * chore(ci): lower mobile pairing LOC baseline * fix(mobile): complete pairing validation artifacts * ci: remove stale transcript target export baseline * fix(ios): wrap access upgrade guidance * fix(ios): preserve localized access key * fix(ci): dedupe read-only SQLite guardrail * fix(mobile): secure full-access node setup
This commit is contained in:
@@ -900,13 +900,12 @@ class GatewaySession(
|
||||
): List<String>? =
|
||||
when (role.trim()) {
|
||||
"node" -> emptyList()
|
||||
// Setup-code bootstrap handoff is deliberately least-privilege. It never
|
||||
// persists operator.admin, so Skill Workshop lifecycle actions remain
|
||||
// disabled until shared token/password auth or an owner-approved scope
|
||||
// upgrade issues an admin-scoped operator device token.
|
||||
// The Gateway bounds setup-code handoff to a closed mobile profile. Persist
|
||||
// only the supported full or limited scope set and drop unexpected extras.
|
||||
"operator" -> {
|
||||
val allowedOperatorScopes =
|
||||
setOf(
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
|
||||
@@ -480,6 +480,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
|
||||
"Fork" to R.string.native_8e5b1a73152cf01c,
|
||||
"Forward Notifications" to R.string.native_fa0ddca1671f4d61,
|
||||
"Forwarding Mode" to R.string.native_9e9bef85bb320b81,
|
||||
"Full" to R.string.native_008dacb6d1e85bd8,
|
||||
"Gateway" to R.string.native_41ed52921661c7f0,
|
||||
"Gateway Pending" to R.string.native_7a1058bb81879dbd,
|
||||
"Gateway URL" to R.string.native_3d06950825de7452,
|
||||
@@ -591,6 +592,8 @@ internal val nativeStringResourceIds: Map<String, Int> =
|
||||
"Layout: Detailed" to R.string.native_f2cb21c53a33f72c,
|
||||
"Level" to R.string.native_1709305c9fa966d1,
|
||||
"Licenses" to R.string.native_6d5d9004797377e1,
|
||||
"Limited" to R.string.native_e5125d9f63d2947b,
|
||||
"Limited Gateway access" to R.string.native_7c79875c4f9d1990,
|
||||
"Linked" to R.string.native_bfda026e6c598dde,
|
||||
"Linked phones and node hosts will appear here after pairing." to R.string.native_be969becb173e0c5,
|
||||
"Listen" to R.string.native_225d29f6201e1a63,
|
||||
@@ -1237,6 +1240,7 @@ internal val nativeStringResourceIds: Map<String, Int> =
|
||||
"Usage" to R.string.native_8d59829c1e15afe1,
|
||||
"Use OpenClaw from your phone" to R.string.native_38dcea3e5a6a342e,
|
||||
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access." to R.string.native_4902797187db2367,
|
||||
"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades." to R.string.native_57efc4ebe625cab5,
|
||||
"Use only on a trusted private network." to R.string.native_24e1028cd7aef320,
|
||||
"Use setup code" to R.string.native_57666930af1af35e,
|
||||
"Use the Gateway computer's LAN address or secure remote hostname." to R.string.native_1291833ee42d8143,
|
||||
|
||||
@@ -1339,6 +1339,7 @@ private fun GatewaySettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val operatorAdminScopeAvailable by viewModel.operatorAdminScopeAvailable.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
@@ -1438,6 +1439,13 @@ private fun GatewaySettingsScreen(
|
||||
listOf(
|
||||
SettingsMetric(nativeString("Connection"), if (gatewayConnectionDisplay.isConnected) nativeString("Connected") else nativeString("Offline")),
|
||||
SettingsMetric(nativeString("Node"), if (isNodeConnected) nativeString("Online") else nativeString("Not paired")),
|
||||
SettingsMetric(
|
||||
nativeString("Access"),
|
||||
gatewayAccessLabel(
|
||||
isConnected = gatewayConnectionDisplay.isConnected,
|
||||
operatorAdminScopeAvailable = operatorAdminScopeAvailable,
|
||||
),
|
||||
),
|
||||
SettingsMetric(nativeString("Gateway"), serverName?.takeIf { it.isNotBlank() } ?: nativeString("Home Gateway")),
|
||||
SettingsMetric(nativeString("Address"), remoteAddress?.takeIf { it.isNotBlank() } ?: nativeString("Not available")),
|
||||
SettingsMetric(
|
||||
@@ -1446,6 +1454,22 @@ private fun GatewaySettingsScreen(
|
||||
),
|
||||
),
|
||||
)
|
||||
if (gatewayConnectionDisplay.isConnected && !operatorAdminScopeAvailable) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = nativeString("Limited Gateway access"),
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = gatewayLimitedAccessUpgradeText(),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawPrimaryButton(text = nativeString("Reconnect"), onClick = viewModel::refreshGatewayConnection, modifier = Modifier.weight(1f))
|
||||
ClawSecondaryButton(text = nativeString("Disconnect"), onClick = viewModel::disconnect, modifier = Modifier.weight(1f))
|
||||
@@ -1587,6 +1611,21 @@ private fun GatewaySettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayAccessLabel(
|
||||
isConnected: Boolean,
|
||||
operatorAdminScopeAvailable: Boolean,
|
||||
): String =
|
||||
when {
|
||||
!isConnected -> nativeString("Not available")
|
||||
operatorAdminScopeAvailable -> nativeString("Full")
|
||||
else -> nativeString("Limited")
|
||||
}
|
||||
|
||||
internal fun gatewayLimitedAccessUpgradeText(): String =
|
||||
nativeString(
|
||||
"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades.",
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun AppearanceSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تعذّر تجهيز مرفق للإرسال."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"الميكروفون متوقف"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"عرض تنبيهات OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تمت الموافقة والحفظ."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"عرض سجل المكالمات الأخيرة"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 قيد الانتظار"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"يتم عرض أحدث جزء من السجل."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"استخدام رمز الإعداد"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"محدد"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"يمكن لـ Android مسح رمز إعداد موجود أو لصقه، لكن هذه البوابة لا تتيح بعد إنشاء رمز الإعداد للتطبيق. أنشئ رمز QR/الرمز على مضيف البوابة باستخدام openclaw qr، ثم امسحه هنا أو الصق رمز الإعداد أدناه."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"في قائمة الانتظار — سيُرسل عند إعادة الاتصال"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"قبل %1$s د"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ التحقق من صلاحية الاقتران"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ تشغيل الأدوات..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جارٍ التحقق من الموافقة…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"التقاط الصور والمقاطع من هذا الهاتف"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إعادة الاتصال بـ Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"جهة خارجية"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"مراجعة الجاهزية"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"محدود"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"شعار OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"إلغاء تثبيت النموذج"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"أسطح المراسلة المتصلة بهذا Gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ein Anhang konnte nicht für den Versand vorbereitet werden."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon aus"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw-Warnmeldungen anzeigen"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Genehmigung erteilt und gespeichert."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Letzte Anrufe anzeigen"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 ausstehend"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Der neueste Protokollausschnitt wird angezeigt."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Einrichtungscode verwenden"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ausgewählt"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android kann einen vorhandenen Einrichtungscode scannen oder einfügen, aber dieses Gateway stellt der App die Erstellung von Einrichtungscodes noch nicht bereit. Erstellen Sie den QR-Code/Code auf dem Gateway-Host mit openclaw qr und scannen Sie ihn dann hier oder fügen Sie den Einrichtungscode unten ein."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"In Warteschlange — wird nach erneuter Verbindung gesendet"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"vor %1$s Min."</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kopplungszugriff wird geprüft"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tools werden ausgeführt..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Genehmigung wird geprüft…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Fotos und Clips mit diesem Telefon aufnehmen"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway erneut verbinden"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Drittanbieter"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bereitschaft prüfen"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Eingeschränkt"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw-Logo"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modell lösen"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mit dieser Gateway verbundene Messaging-Oberflächen."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"No se pudo preparar un archivo adjunto para enviarlo."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Micrófono apagado"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostrar alertas de OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aprobación permitida y guardada."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostrar el historial de llamadas recientes"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 pendiente"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostrando el fragmento de registro más reciente."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Usar código de configuración"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Seleccionado"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android puede escanear o pegar un código de configuración existente, pero este gateway aún no expone la generación de códigos de configuración a la app. Genera el QR/código en el host del gateway con openclaw qr y luego escanéalo aquí o pega el código de configuración a continuación."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En cola — se enviará al volver a conectar"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"hace %1$s min"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Comprobando el acceso de emparejamiento"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ejecutando herramientas..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Comprobando aprobación…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Captura fotos y clips desde este teléfono"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconectar Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"De terceros"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Revisar preparación"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limitado"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logotipo de OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dejar de fijar modelo"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Superficies de mensajería conectadas a este Gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"پیوست برای ارسال آماده نشد."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"میکروفون خاموش"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نمایش هشدارهای OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"تأیید اجازه داده شد و ذخیره شد."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"نمایش سابقه تماسهای اخیر"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"۱ در انتظار"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال نمایش آخرین بخش گزارش."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"استفاده از کد راهاندازی"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"انتخابشده"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android میتواند یک کد راهاندازی موجود را اسکن یا جایگذاری کند، اما این gateway هنوز تولید کد راهاندازی را در اختیار برنامه قرار نمیدهد. QR/کد را روی میزبان gateway با openclaw qr تولید کنید، سپس آن را اینجا اسکن کنید یا کد راهاندازی را در پایین جایگذاری کنید."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در صف — پس از اتصال مجدد ارسال میشود"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s دقیقه پیش"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال بررسی دسترسی جفتسازی"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال اجرای ابزارها..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"در حال بررسی تأیید…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"گرفتن عکس و کلیپ با این تلفن"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"اتصال دوباره به Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"شخص ثالث"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"بررسی آمادگی"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"محدود"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"لوگوی OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"برداشتن سنجاق مدل"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"سطوح پیامرسانی متصل به این Gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Impossible de préparer une pièce jointe pour l’envoi."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Micro désactivé"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Afficher les alertes OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Approbation autorisée et enregistrée."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Afficher l’historique récent des appels"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 en attente"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Affichage du dernier bloc de journal."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Utiliser le code de configuration"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sélectionné"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android peut scanner ou coller un code de configuration existant, mais ce gateway n’expose pas encore la génération de code de configuration à l’application. Générez le QR/code sur l’hôte du gateway avec openclaw qr, puis scannez-le ici ou collez le code de configuration ci-dessous."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En attente — envoi lors de la reconnexion"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"il y a %1$s min"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vérification de l’accès d’appairage"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Exécution des outils..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vérification de l’approbation…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Prendre des photos et enregistrer des clips avec ce téléphone"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconnecter le Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tiers"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vérifier l’état de préparation"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limité"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logo OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Désépingler le modèle"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Surfaces de messagerie connectées à ce Gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"भेजने के लिए अटैचमेंट तैयार नहीं किया जा सका।"</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"माइक बंद"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw अलर्ट दिखाएँ"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"स्वीकृति की अनुमति दी गई और सहेजी गई।"</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"हाल का कॉल इतिहास दिखाएँ"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 लंबित"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"नवीनतम लॉग खंड दिखाया जा रहा है।"</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सेटअप कोड का उपयोग करें"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"चयनित"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android मौजूदा सेटअप कोड को स्कैन या पेस्ट कर सकता है, लेकिन यह gateway अभी ऐप को सेटअप-कोड जनरेशन उपलब्ध नहीं कराता है। gateway host पर openclaw qr के साथ QR/कोड जनरेट करें, फिर उसे यहाँ स्कैन करें या नीचे सेटअप कोड पेस्ट करें।"</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"कतार में — दोबारा कनेक्ट होने पर भेजा जाएगा"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$sमि. पहले"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"पेयरिंग एक्सेस की जाँच की जा रही है"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"टूल चला रहा है..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"स्वीकृति जाँची जा रही है…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"इस फ़ोन से फ़ोटो और क्लिप कैप्चर करें"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway फिर से कनेक्ट करें"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"तृतीय-पक्ष"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"तत्परता की समीक्षा करें"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"सीमित"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw लोगो"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"मॉडल अनपिन करें"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"इस gateway से जुड़ी मैसेजिंग सतहें।"</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tidak dapat menyiapkan lampiran untuk dikirim."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon mati"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tampilkan peringatan OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Persetujuan diizinkan dan disimpan."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tampilkan riwayat panggilan terbaru"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 tertunda"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Menampilkan potongan log terbaru."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gunakan kode penyiapan"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dipilih"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android dapat memindai atau menempelkan kode penyiapan yang sudah ada, tetapi gateway ini belum menyediakan pembuatan kode penyiapan ke aplikasi. Buat QR/kode di host gateway dengan openclaw qr, lalu pindai di sini atau tempelkan kode penyiapan di bawah."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Dalam antrean — dikirim saat terhubung kembali"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s mnt lalu"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Memeriksa akses pemasangan"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Menjalankan alat..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Memeriksa persetujuan…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ambil foto dan klip dari ponsel ini"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hubungkan ulang gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Pihak ketiga"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tinjau kesiapan"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Terbatas"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logo OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lepas sematan model"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Permukaan perpesanan yang terhubung ke gateway ini."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Impossibile preparare un allegato per l\'invio."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microfono disattivato"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostra gli avvisi di OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Approvazione consentita e salvata."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostra la cronologia delle chiamate recenti"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 in sospeso"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Visualizzazione dell\'ultimo blocco di log."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Usa codice di configurazione"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Selezionato"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android può scansionare o incollare un codice di configurazione esistente, ma questo gateway non espone ancora all\'app la generazione dei codici di configurazione. Genera il QR/codice sull\'host del gateway con openclaw qr, quindi scansionalo qui o incolla il codice di configurazione qui sotto."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"In coda — verrà inviato alla riconnessione"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s min fa"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verifica dell\'accesso all\'associazione"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Esecuzione degli strumenti..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verifica dell\'approvazione…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Scatta foto e registra clip con questo telefono"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Riconnetti gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Di terze parti"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verifica disponibilità"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limitato"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logo OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Rimuovi modello dagli elementi in evidenza"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Superfici di messaggistica connesse a questo gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"送信用の添付ファイルを準備できませんでした。"</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"マイク オフ"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClawのアラートを表示"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"承認を許可して保存しました。"</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"最近の通話履歴を表示"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 件保留中"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"最新のログチャンクを表示しています。"</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"セットアップコードを使用"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"選択済み"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android では既存のセットアップコードをスキャンまたは貼り付けできますが、この Gateway はまだアプリにセットアップコード生成機能を公開していません。Gateway ホストで openclaw qr を使用して QR/code を生成し、ここでスキャンするか、下にセットアップコードを貼り付けてください。"</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"キューに追加済み — 再接続時に送信されます"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s分前"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ペアリングアクセスを確認中"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ツールを実行中..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"承認を確認中…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"このスマートフォンで写真や動画を撮影"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway に再接続"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"サードパーティ"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"準備状況を確認"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"制限あり"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw ロゴ"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"モデルのピン留めを解除"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"この Gateway に接続されているメッセージング画面。"</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"전송할 첨부 파일을 준비할 수 없습니다."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"마이크 꺼짐"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 알림 표시"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"승인이 허용되고 저장되었습니다."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"최근 통화 기록 표시"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1개 대기 중"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"최신 로그 청크를 표시하고 있습니다."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"설정 코드 사용"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"선택됨"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android는 기존 설정 코드를 스캔하거나 붙여넣을 수 있지만, 이 gateway는 아직 앱에 설정 코드 생성을 제공하지 않습니다. gateway 호스트에서 openclaw qr로 QR/코드를 생성한 다음 여기에서 스캔하거나 아래에 설정 코드를 붙여넣으세요."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"대기 중 — 다시 연결되면 전송"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s분 전"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"페어링 액세스 확인 중"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"도구 실행 중..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"승인 확인 중…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"이 휴대전화에서 사진 및 클립 촬영"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway 다시 연결"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"서드 파티"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"준비 상태 검토"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"제한됨"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 로고"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"모델 고정 해제"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"이 Gateway에 연결된 메시징 표면."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kan een bijlage niet gereedmaken voor verzending."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microfoon uit"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw-waarschuwingen tonen"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Goedkeuring toegestaan en opgeslagen."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Recente oproepgeschiedenis tonen"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 in behandeling"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"De nieuwste logchunk wordt weergegeven."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Setupcode gebruiken"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Geselecteerd"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android kan een bestaande setupcode scannen of plakken, maar deze gateway biedt de app nog geen mogelijkheid om setupcodes te genereren. Genereer de QR/code op de gatewayhost met openclaw qr en scan deze vervolgens hier of plak de setupcode hieronder."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"In wachtrij — wordt verzonden zodra de verbinding is hersteld"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$sm geleden"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Koppeltoegang controleren"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tools worden uitgevoerd..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Goedkeuring controleren…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Foto\'s en clips vastleggen met deze telefoon"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway opnieuw verbinden"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Derde partij"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gereedheid controleren"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Beperkt"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw-logo"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Model losmaken"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Berichtenoppervlakken die met deze Gateway zijn verbonden."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Nie udało się przygotować załącznika do wysłania."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon wyłączony"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wyświetlanie alertów OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Zatwierdzenie dozwolone i zapisane."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wyświetlanie historii ostatnich połączeń"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 oczekujący"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wyświetlany jest najnowszy fragment logu."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Użyj kodu konfiguracji"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Wybrano"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android może zeskanować lub wkleić istniejący kod konfiguracji, ale ten gateway nie udostępnia jeszcze aplikacji generowania kodu konfiguracji. Wygeneruj QR/kod na hoście gateway za pomocą openclaw qr, a następnie zeskanuj go tutaj lub wklej kod konfiguracji poniżej."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"W kolejce — zostanie wysłane po ponownym połączeniu"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s min temu"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprawdzanie dostępu do parowania"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Uruchamianie narzędzi..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprawdzanie zatwierdzenia…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Rób zdjęcia i nagrywaj klipy tym telefonem"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Połącz ponownie z Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Innej firmy"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sprawdź gotowość"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ograniczone"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logo OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Odepnij model"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Powierzchnie komunikacji połączone z tym Gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Não foi possível preparar um anexo para envio."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Microfone desligado"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostrar alertas do OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Aprovação permitida e salva."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostrar histórico de chamadas recentes"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 pendente"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mostrando o bloco de log mais recente."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Usar código de configuração"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Selecionado"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"O Android pode escanear ou colar um código de configuração existente, mas este Gateway ainda não expõe a geração de códigos de configuração para o app. Gere o QR/código no host do Gateway com openclaw qr e, em seguida, escaneie-o aqui ou cole o código de configuração abaixo."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Na fila — será enviado quando a conexão for restabelecida"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"há %1$s min"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verificando acesso de pareamento"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Executando ferramentas..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Verificando aprovação…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Capturar fotos e clipes deste telefone"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconectar Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Terceiros"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Revisar prontidão"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limitado"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logotipo do OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Desafixar modelo"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Superfícies de mensagens conectadas a este gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не удалось подготовить вложение к отправке."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Микрофон выключен"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Показывать оповещения OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Одобрение разрешено и сохранено."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Показывать историю недавних вызовов"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 в ожидании"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Показан последний фрагмент журнала."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Использовать код настройки"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Выбрано"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android может отсканировать или вставить существующий код настройки, но этот gateway пока не предоставляет приложению возможность создавать коды настройки. Создайте QR-код/код на хосте gateway с помощью openclaw qr, затем отсканируйте его здесь или вставьте код настройки ниже."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"В очереди — будет отправлено после переподключения"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s мин назад"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Проверка доступа для сопряжения"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Выполняются инструменты..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Проверка одобрения…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Снимать фото и видео на этом телефоне"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторно подключить Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Стороннее"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Проверить готовность"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ограниченный доступ"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Логотип OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Открепить модель"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Поверхности обмена сообщениями, подключенные к этому Gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Det gick inte att förbereda en bilaga för att skickas."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon av"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Visa aviseringar från OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Godkännande tillåtet och sparat."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Visa senaste samtalshistoriken"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 väntande"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Visar det senaste loggavsnittet."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Använd konfigurationskod"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Vald"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android kan skanna eller klistra in en befintlig konfigurationskod, men denna gateway exponerar ännu inte generering av konfigurationskoder för appen. Generera QR-koden/koden på gateway-värden med openclaw qr och skanna den sedan här eller klistra in konfigurationskoden nedan."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"I kö — skickas när anslutningen återupprättas"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s min sedan"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kontrollerar parkopplingsåtkomst"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kör verktyg..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kontrollerar godkännande…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ta foton och spela in klipp med den här telefonen"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Återanslut Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Tredjepart"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Granska beredskap"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Begränsad"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw-logotyp"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Lossa modell"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Meddelandeytor anslutna till denna Gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ไม่สามารถเตรียมไฟล์แนบสำหรับส่งได้"</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ปิดไมค์"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"แสดงการแจ้งเตือนจาก OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"อนุมัติและบันทึกแล้ว"</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"แสดงประวัติการโทรล่าสุด"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"รอดำเนินการ 1 รายการ"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังแสดงส่วนบันทึกล่าสุด"</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ใช้รหัสตั้งค่า"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เลือกแล้ว"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android สามารถสแกนหรือวางรหัสตั้งค่าที่มีอยู่ได้ แต่ Gateway นี้ยังไม่เปิดให้แอปสร้างรหัสตั้งค่า สร้าง QR/รหัสบนโฮสต์ Gateway ด้วย openclaw qr จากนั้นสแกนที่นี่หรือวางรหัสตั้งค่าด้านล่าง"</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"อยู่ในคิว — จะส่งเมื่อเชื่อมต่ออีกครั้ง"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s นาทีที่แล้ว"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังตรวจสอบสิทธิ์การจับคู่"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังเรียกใช้เครื่องมือ..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"กำลังตรวจสอบการอนุมัติ…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ถ่ายภาพและคลิปจากโทรศัพท์เครื่องนี้"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เชื่อมต่อ Gateway อีกครั้ง"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"บุคคลที่สาม"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"ตรวจสอบความพร้อม"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"จำกัด"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"โลโก้ OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"เลิกปักหมุดโมเดล"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"พื้นผิวการส่งข้อความที่เชื่อมต่อกับ Gateway นี้"</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Ek, gönderilmek üzere hazırlanamadı."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mikrofon kapalı"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw uyarılarını göster"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Onay verildi ve kaydedildi."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Son arama geçmişini göster"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 beklemede"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"En son günlük parçası gösteriliyor."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kurulum kodunu kullan"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Seçildi"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android mevcut bir kurulum kodunu tarayabilir veya yapıştırabilir, ancak bu gateway henüz uygulamaya kurulum kodu oluşturma özelliği sunmuyor. Gateway ana makinesinde openclaw qr ile QR/kod oluşturun, ardından burada tarayın veya kurulum kodunu aşağıya yapıştırın."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sıraya alındı — yeniden bağlanıldığında gönderilecek"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s dk önce"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Eşleştirme erişimi kontrol ediliyor"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Araçlar çalıştırılıyor..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Onay kontrol ediliyor…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bu telefondan fotoğraf ve klip çekin"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Gateway’i yeniden bağla"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Üçüncü taraf"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hazır olma durumunu incele"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sınırlı"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw logosu"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Modelin sabitlemesini kaldır"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bu gateway’e bağlı mesajlaşma yüzeyleri."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Не вдалося підготувати вкладення до надсилання."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Мікрофон вимкнено"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Показувати сповіщення OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Схвалення дозволено та збережено."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Показувати недавню історію викликів"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 в очікуванні"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Показано останній фрагмент журналу."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Використати код налаштування"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Вибрано"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android може відсканувати або вставити наявний код налаштування, але цей gateway ще не надає застосунку можливість створювати коди налаштування. Згенеруйте QR/код на хості gateway за допомогою openclaw qr, а потім відскануйте його тут або вставте код налаштування нижче."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"У черзі — буде надіслано після відновлення з’єднання"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s хв тому"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Перевірка доступу для спарювання"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Виконуються інструменти..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Перевірка схвалення…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Знімати фото й відеокліпи цим телефоном"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Повторно підключити gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Сторонній"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Перевірка готовності"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Обмежено"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Логотип OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Відкріпити модель"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Поверхні обміну повідомленнями, підключені до цього Gateway."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Không thể chuẩn bị tệp đính kèm để gửi."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mic tắt"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hiển thị cảnh báo của OpenClaw"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đã cho phép và lưu phê duyệt."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hiển thị lịch sử cuộc gọi gần đây"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 đang chờ"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang hiển thị phần nhật ký mới nhất."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Sử dụng mã thiết lập"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đã chọn"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android có thể quét hoặc dán mã thiết lập hiện có, nhưng gateway này chưa cung cấp tính năng tạo mã thiết lập cho ứng dụng. Tạo QR/mã trên máy chủ gateway bằng openclaw qr, sau đó quét tại đây hoặc dán mã thiết lập bên dưới."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đã xếp hàng — sẽ gửi khi kết nối lại"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s phút trước"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang kiểm tra quyền ghép nối"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang chạy công cụ..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Đang kiểm tra phê duyệt…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Chụp ảnh và quay đoạn phim bằng điện thoại này"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Kết nối lại Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bên thứ ba"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Xem xét mức độ sẵn sàng"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Hạn chế"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Logo OpenClaw"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Bỏ ghim mô hình"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Các bề mặt nhắn tin được kết nối với gateway này."</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"无法准备待发送的附件。"</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"麦克风关闭"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"显示 OpenClaw 提醒"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已批准并保存。"</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"显示最近的通话记录"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 个待处理"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在显示最新的日志块。"</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"使用设置代码"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已选择"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android 可以扫描或粘贴现有设置码,但此 gateway 目前尚未向应用开放设置码生成功能。请在 gateway 主机上使用 openclaw qr 生成二维码/代码,然后在此扫描或在下方粘贴设置码。"</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已加入队列 — 重新连接后发送"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s 分钟前"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在检查配对访问权限"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在运行工具..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在检查批准状态…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"使用此手机拍摄照片和视频片段"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"重新连接 Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"第三方"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"检查就绪状态"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"受限"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 标志"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"取消固定模型"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"连接到此 Gateway 的消息界面。"</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"無法準備要傳送的附件。"</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"麥克風關閉"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"顯示 OpenClaw 提醒"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已允許核准並儲存。"</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"顯示最近的通話記錄"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 個待處理"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在顯示最新的記錄區塊。"</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"使用設定代碼"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已選取"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android 可以掃描或貼上現有的設定代碼,但此 gateway 尚未向 App 開放設定代碼產生功能。請在 gateway 主機上使用 openclaw qr 產生 QR/code,然後在此掃描,或在下方貼上設定代碼。"</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"已排入佇列 — 重新連線後傳送"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$s 分鐘前"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在檢查配對存取權限"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在執行工具..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"正在檢查核准狀態…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"使用此手機拍攝相片和短片"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"重新連線 Gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"第三方"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"檢視就緒狀態"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"受限"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw 標誌"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"取消釘選模型"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"連線至此 Gateway 的訊息介面。"</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="native_002a6fcb30b2def4" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Could not stage an attachment for sending."</string>
|
||||
<string name="native_003aaf9e0a0e6b9f" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Mic off"</string>
|
||||
<string name="native_0060cbfdd3cf13ba" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Show OpenClaw alerts"</string>
|
||||
<string name="native_008dacb6d1e85bd8" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Full"</string>
|
||||
<string name="native_009506aeb0484b2a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Approval allowed and saved."</string>
|
||||
<string name="native_00d108e2e6466940" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Show recent call history"</string>
|
||||
<string name="native_00e8aeba41d79ce3" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"1 pending"</string>
|
||||
@@ -483,6 +484,7 @@
|
||||
<string name="native_572739b05d138f8a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Showing the latest log chunk."</string>
|
||||
<string name="native_57666930af1af35e" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use setup code"</string>
|
||||
<string name="native_57c4b13a4c7335e7" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"sticker"</string>
|
||||
<string name="native_57efc4ebe625cab5" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades."</string>
|
||||
<string name="native_57fce44d7c6df51a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"steer"</string>
|
||||
<string name="native_57fd7a0cf33f2666" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Selected"</string>
|
||||
<string name="native_58268edca12a81a1" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below."</string>
|
||||
@@ -695,6 +697,7 @@
|
||||
<string name="native_7bda957a27b2f876" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Queued — sends when reconnected"</string>
|
||||
<string name="native_7c55b5030e55a687" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"%1$sm ago"</string>
|
||||
<string name="native_7c5c9a83536e33d2" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Checking pairing access"</string>
|
||||
<string name="native_7c79875c4f9d1990" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited Gateway access"</string>
|
||||
<string name="native_7cc20d4753b8fd45" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Running tools..."</string>
|
||||
<string name="native_7d2227b96bc40069" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Checking approval…"</string>
|
||||
<string name="native_7d3951124ae2ca1d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Capture photos and clips from this phone"</string>
|
||||
@@ -1251,6 +1254,7 @@
|
||||
<string name="native_e4582b6fb1f57494" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Reconnect gateway"</string>
|
||||
<string name="native_e4696640127b9126" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Third-party"</string>
|
||||
<string name="native_e482357257d6c2fd" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Review readiness"</string>
|
||||
<string name="native_e5125d9f63d2947b" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Limited"</string>
|
||||
<string name="native_e51aa2de89dcd71a" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"OpenClaw logo"</string>
|
||||
<string name="native_e52b3044724b9f4d" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Unpin model"</string>
|
||||
<string name="native_e5584bc5057284ae" formatted="false" tools:ignore="Typos,TypographyDashes,TypographyEllipsis">"Messaging surfaces connected to this gateway."</string>
|
||||
|
||||
@@ -704,7 +704,13 @@ class GatewaySessionInvokeTest {
|
||||
assertEquals(emptyList<String>(), nodeEntry?.scopes)
|
||||
assertEquals("bootstrap-operator-token", operatorEntry?.token)
|
||||
assertEquals(
|
||||
listOf("operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"),
|
||||
listOf(
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
),
|
||||
operatorEntry?.scopes,
|
||||
)
|
||||
} finally {
|
||||
|
||||
@@ -139,6 +139,17 @@ class SettingsScreensTest {
|
||||
assertEquals(true, text.contains("node capability approval"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayAccessExplainsLimitedConnectionsAndUpgradePath() {
|
||||
assertEquals("Not available", gatewayAccessLabel(isConnected = false, operatorAdminScopeAvailable = false))
|
||||
assertEquals("Limited", gatewayAccessLabel(isConnected = true, operatorAdminScopeAvailable = false))
|
||||
assertEquals("Full", gatewayAccessLabel(isConnected = true, operatorAdminScopeAvailable = true))
|
||||
assertTrue(gatewayLimitedAccessUpgradeText().contains("full-access setup code"))
|
||||
assertTrue(gatewayLimitedAccessUpgradeText().contains("wss://"))
|
||||
assertTrue(gatewayLimitedAccessUpgradeText().contains("Tailscale Serve"))
|
||||
assertTrue(gatewayLimitedAccessUpgradeText().contains("settings and upgrades"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun devicePairingAdminCopySeparatesPairingFromNodeApproval() {
|
||||
val text = devicePairingAdminUnavailableText()
|
||||
|
||||
@@ -336,6 +336,34 @@ extension SettingsProTab {
|
||||
SettingsDetailRow(
|
||||
"Agents",
|
||||
value: .verbatim(self.appModel.gatewayAgents.count.formatted()))
|
||||
SettingsDetailRow(
|
||||
"Access",
|
||||
value: .verbatim(
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
? (self.appModel.hasOperatorAdminScope ? "Full" : "Limited")
|
||||
: "Not available"))
|
||||
}
|
||||
|
||||
if self.appModel.isOperatorGatewayConnected,
|
||||
!self.appModel.hasOperatorAdminScope
|
||||
{
|
||||
Section("Upgrade access") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("This phone has limited Gateway access.")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(
|
||||
"Use a secure wss:// or Tailscale Serve Gateway, then scan a full-access setup code from the Control UI or openclaw qr and reconnect to enable settings and upgrades.") // swiftlint:disable:this line_length
|
||||
.font(OpenClawType.caption) // Keep the native localization key contiguous.
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Button {
|
||||
self.openGatewayQRScanner()
|
||||
} label: {
|
||||
Label("Scan Full-Access Code", systemImage: "qrcode.viewfinder")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.agentSelectionCard
|
||||
|
||||
@@ -63,6 +63,9 @@ extension RootTabsSourceGuardTests {
|
||||
#expect(scannerLifecycle.contains("self.stopScannerCapture()"))
|
||||
|
||||
#expect(sectionsSource.contains("var gatewayDestination: some View"))
|
||||
#expect(sectionsSource.contains("This phone has limited Gateway access."))
|
||||
#expect(sectionsSource.contains("Use a secure wss:// or Tailscale Serve Gateway"))
|
||||
#expect(sectionsSource.contains("Label(\"Scan Full-Access Code\""))
|
||||
#expect(sectionsSource.contains("self.gatewayActions"))
|
||||
#expect(sectionsSource.contains("self.manualGatewayCard"))
|
||||
#expect(sectionsSource.contains("self.gatewaySetupCard"))
|
||||
|
||||
@@ -769,7 +769,7 @@ extension GatewayChannelActor {
|
||||
}
|
||||
guard scheme == "ws", let host = self.url.host else { return false }
|
||||
// Setup codes intentionally allow plaintext WebSocket bootstrap on local networks
|
||||
// for QR pairing. Persist the resulting bounded device token so reconnects do not
|
||||
// for QR pairing. Persist the resulting server-bounded device token so reconnects do not
|
||||
// fall back to auth=none after the single-use bootstrap token is cleared.
|
||||
return LoopbackHost.isLocalNetworkHost(host)
|
||||
}
|
||||
@@ -781,6 +781,7 @@ extension GatewayChannelActor {
|
||||
return []
|
||||
case "operator":
|
||||
let allowedOperatorScopes: Set = [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
|
||||
@@ -11474,6 +11474,8 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
|
||||
public let gatewayurls: [String]?
|
||||
public let auth: AnyCodable
|
||||
public let urlsource: String
|
||||
public let access: AnyCodable?
|
||||
public let accessdowngraded: Bool?
|
||||
|
||||
public init(
|
||||
setupcode: String,
|
||||
@@ -11481,7 +11483,9 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
|
||||
gatewayurl: String,
|
||||
gatewayurls: [String]? = nil,
|
||||
auth: AnyCodable,
|
||||
urlsource: String)
|
||||
urlsource: String,
|
||||
access: AnyCodable? = nil,
|
||||
accessdowngraded: Bool? = nil)
|
||||
{
|
||||
self.setupcode = setupcode
|
||||
self.qrdataurl = qrdataurl
|
||||
@@ -11489,6 +11493,8 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
|
||||
self.gatewayurls = gatewayurls
|
||||
self.auth = auth
|
||||
self.urlsource = urlsource
|
||||
self.access = access
|
||||
self.accessdowngraded = accessdowngraded
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -11498,6 +11504,8 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
|
||||
case gatewayurls = "gatewayUrls"
|
||||
case auth
|
||||
case urlsource = "urlSource"
|
||||
case access
|
||||
case accessdowngraded = "accessDowngraded"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2569,6 +2569,7 @@ struct GatewayNodeSessionTests {
|
||||
#expect(nodeEntry.scopes == [])
|
||||
#expect(operatorEntry.token == "operator-device-token")
|
||||
#expect(operatorEntry.scopes == [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
|
||||
+22
-10
@@ -117,8 +117,11 @@ Use an already connected Control UI session with `operator.admin` access:
|
||||
|
||||
1. Open the Control UI and select **Nodes**.
|
||||
2. On the **Devices** page, click **Pair mobile device**.
|
||||
3. On your phone, open the OpenClaw app → **Settings** → **Gateway**.
|
||||
4. Scan the QR code or paste the setup code, then connect.
|
||||
3. Keep **Full access (recommended)**, or select **Limited access** to omit
|
||||
administrative Gateway controls.
|
||||
4. Click **Create setup code**.
|
||||
5. On your phone, open the OpenClaw app → **Settings** → **Gateway**.
|
||||
6. Scan the QR code or paste the setup code, then connect.
|
||||
|
||||
Official OpenClaw iOS and Android apps are approved automatically when their
|
||||
setup-code metadata matches. If **Pending approval** shows a request (for
|
||||
@@ -150,23 +153,32 @@ Run `/pair cleanup` to invalidate unused setup codes once pairing finishes.
|
||||
|
||||
That bootstrap token carries the built-in pairing bootstrap profile:
|
||||
|
||||
- the built-in setup profile allows the fresh QR/setup-code baseline only:
|
||||
`node` plus a bounded `operator` handoff
|
||||
- a secure `wss://` setup (or same-host loopback) defaults to `node` plus full
|
||||
native-mobile `operator` access
|
||||
- the handed-off `node` token stays `scopes: []`
|
||||
- the handed-off `operator` token is limited to `operator.approvals`,
|
||||
`operator.read`, `operator.talk.secrets`, and `operator.write`
|
||||
- `operator.admin` is not granted by QR/setup-code bootstrap; it requires a
|
||||
separate approved operator pairing or token flow
|
||||
- the default handed-off `operator` token includes `operator.admin`,
|
||||
`operator.approvals`, `operator.read`, `operator.talk.secrets`, and
|
||||
`operator.write`
|
||||
- Control UI **Limited access** and `openclaw qr --limited` omit
|
||||
`operator.admin` while keeping the other operator scopes
|
||||
- plaintext LAN `ws://` setup automatically uses the same limited profile;
|
||||
configure `wss://` or Tailscale Serve and generate a new code for full access
|
||||
- later token rotation/revocation remains bounded by both the device's approved
|
||||
role contract and the caller session's operator scopes
|
||||
|
||||
Treat the setup code like a password while it is valid.
|
||||
|
||||
The iOS and Android **Settings → Gateway** pages show **Full** or **Limited**
|
||||
access. To upgrade a limited phone, first configure a secure `wss://` or
|
||||
Tailscale Serve route, then generate a new full-access setup code, scan or paste
|
||||
it in that settings page, and reconnect.
|
||||
|
||||
For Tailscale, public, or other remote mobile pairing, use Tailscale Serve/Funnel
|
||||
or another `wss://` Gateway URL. Plaintext `ws://` setup codes are accepted only
|
||||
for loopback, private LAN addresses, `.local` Bonjour hosts, and the Android
|
||||
emulator host. Tailnet CGNAT addresses, `.ts.net` names, and public hosts still
|
||||
fail closed before QR/setup-code issuance.
|
||||
emulator host. Non-loopback plaintext routes receive limited access. Tailnet
|
||||
CGNAT addresses, `.ts.net` names, and public hosts still fail closed before
|
||||
QR/setup-code issuance.
|
||||
|
||||
For `gateway.bind=lan` setup URLs, OpenClaw detects persistent Tailscale Serve
|
||||
HTTPS roots that proxy the active Gateway's loopback port and advertises them
|
||||
|
||||
+12
-5
@@ -15,6 +15,7 @@ openclaw qr
|
||||
openclaw qr --setup-code-only
|
||||
openclaw qr --json
|
||||
openclaw qr --remote
|
||||
openclaw qr --limited
|
||||
openclaw qr --url wss://gateway.example/ws
|
||||
```
|
||||
|
||||
@@ -34,24 +35,30 @@ openclaw devices approve <requestId>
|
||||
- `--public-url <url>`: override the public URL used in the payload
|
||||
- `--token <token>`: override the gateway token the bootstrap flow authenticates against
|
||||
- `--password <password>`: override the gateway password the bootstrap flow authenticates against
|
||||
- `--limited`: omit administrative Gateway access from the handed-off operator token
|
||||
- `--setup-code-only`: print only the setup code
|
||||
- `--no-ascii`: skip ASCII QR rendering
|
||||
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, optional `gatewayUrls`, `auth`, `urlSource`)
|
||||
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, optional `gatewayUrls`, `auth`, `access`, optional `accessDowngraded`, `urlSource`)
|
||||
|
||||
`--token` and `--password` are mutually exclusive.
|
||||
|
||||
## Setup code contents
|
||||
|
||||
The setup code carries an opaque, short-lived `bootstrapToken`, not the shared gateway token/password. The built-in bootstrap flow issues:
|
||||
The setup code carries an opaque, short-lived `bootstrapToken`, not the shared gateway token/password. For a `wss://` endpoint (or same-host loopback), the default bootstrap flow issues:
|
||||
|
||||
- a primary `node` token with `scopes: []`
|
||||
- a bounded `operator` handoff token limited to `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`
|
||||
- a full native-mobile `operator` handoff token with `operator.admin`, `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`
|
||||
|
||||
Pairing-mutation scopes and `operator.admin` still require a separate approved operator pairing or token flow.
|
||||
Use `--limited` to keep the same node token while omitting `operator.admin` from the operator handoff. Pairing-mutation scope is never handed off by a setup code.
|
||||
|
||||
Plaintext LAN `ws://` setup remains available, but OpenClaw automatically uses
|
||||
the limited profile because a network observer could capture and race the bearer
|
||||
bootstrap token. Configure `wss://` or Tailscale Serve, then generate a new code
|
||||
to get full access.
|
||||
|
||||
## Gateway URL resolution
|
||||
|
||||
Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs: use Tailscale Serve/Funnel or a `wss://` gateway URL for those. Private LAN addresses and `.local` Bonjour hosts remain supported over plain `ws://`.
|
||||
Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs: use Tailscale Serve/Funnel or a `wss://` gateway URL for those. Private LAN addresses and `.local` Bonjour hosts remain supported over plain `ws://`, with limited operator access as described above.
|
||||
|
||||
When the selected Gateway URL comes from `gateway.bind=lan`, OpenClaw also checks persistent `tailscale serve status --json` routes. Any HTTPS Serve root that proxies the active Gateway's loopback port is included as a fallback. The QR command adds this fallback only for `lan`; `custom` and `tailnet` keep their explicitly advertised routes. Current iOS clients probe the advertised routes in order and save the first reachable one; the legacy `url` field remains unchanged for older clients.
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ For Tailscale or public hosts, Android requires a secure endpoint:
|
||||
|
||||
- Preferred: Tailscale Serve / Funnel with `https://<magicdns>` / `wss://<magicdns>`
|
||||
- Also supported: any other `wss://` Gateway URL with a real TLS endpoint
|
||||
- Cleartext `ws://` remains supported on private LAN addresses / `.local` hosts, plus `localhost`, `127.0.0.1`, and the Android emulator bridge (`10.0.2.2`)
|
||||
- Cleartext `ws://` remains supported on private LAN addresses / `.local` hosts, plus `localhost`, `127.0.0.1`, and the Android emulator bridge (`10.0.2.2`); non-loopback setup automatically uses limited operator access
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -218,6 +218,15 @@ In the Android app:
|
||||
|
||||
After the first successful pairing, Android auto-reconnects on launch to the active paired gateway (best-effort for discovered gateways, which must be visible on the network).
|
||||
|
||||
Official setup codes connect Android as a node and grant full Gateway operator
|
||||
access by default over `wss://`. Plaintext non-loopback `ws://` setup
|
||||
automatically uses limited access for bearer-token safety. **Settings → Gateway**
|
||||
shows **Full** or **Limited** access. For a limited connection, configure
|
||||
`wss://` or Tailscale Serve, generate a new full-access code in Control UI or
|
||||
with `openclaw qr`, then scan or paste it on that page and reconnect. Operators
|
||||
who want the reduced profile can select **Limited access** in Control UI or run
|
||||
`openclaw qr --limited`.
|
||||
|
||||
### Multiple gateways
|
||||
|
||||
The app keeps a registry of every gateway it has paired with, so you can switch between them without pairing again:
|
||||
|
||||
@@ -48,7 +48,9 @@ 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.
|
||||
**Pair mobile 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**.
|
||||
|
||||
3. In the iOS app, open **Settings** -> **Gateway**, scan the QR code (or paste
|
||||
the setup code), and connect.
|
||||
@@ -59,6 +61,12 @@ creation has a token or password auth path.
|
||||
4. The official app connects automatically. If **Pending approval** shows a
|
||||
request, review its role and scopes before approving it.
|
||||
|
||||
**Settings → Gateway** shows whether the saved operator connection has
|
||||
**Full** or **Limited** access. Plaintext LAN `ws://` setup is automatically
|
||||
limited for bearer-token safety. If it is limited, configure `wss://` or
|
||||
Tailscale Serve, scan a new full-access code from Control UI or `openclaw qr`,
|
||||
then reconnect to enable settings and upgrades.
|
||||
|
||||
The Control UI button requires an already paired session with `operator.admin`.
|
||||
As a terminal fallback, pick a discovered gateway in the iOS app (or enable
|
||||
Manual Host and enter host/port), then approve the request on the Gateway host:
|
||||
|
||||
@@ -32,8 +32,8 @@ const pluginApiMocks = vi.hoisted(() => ({
|
||||
vi.mock("./api.js", () => {
|
||||
return {
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
},
|
||||
approveDevicePairing: vi.fn(),
|
||||
clearDeviceBootstrapTokens: pluginApiMocks.clearDeviceBootstrapTokens,
|
||||
@@ -293,7 +293,7 @@ describe("device-pair /pair qr", () => {
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
const payload = result as {
|
||||
@@ -307,8 +307,15 @@ describe("device-pair /pair qr", () => {
|
||||
expect(pluginApiMocks.renderQrPngDataUrl).toHaveBeenCalledTimes(1);
|
||||
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
roles: ["node", "operator"],
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
purpose: "mobile-full",
|
||||
},
|
||||
});
|
||||
expect(text).toContain("Scan this QR code with the OpenClaw iOS app:");
|
||||
@@ -802,7 +809,19 @@ describe("device-pair /pair default setup code", () => {
|
||||
);
|
||||
const text = requireText(result);
|
||||
|
||||
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
||||
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
|
||||
profile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
purpose: "mobile-full",
|
||||
},
|
||||
});
|
||||
expect(text).toContain("Pairing setup code generated.");
|
||||
});
|
||||
|
||||
@@ -826,7 +845,7 @@ describe("device-pair /pair default setup code", () => {
|
||||
channel: "webchat",
|
||||
args: "",
|
||||
commandBody: "/pair",
|
||||
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
const text = requireText(result);
|
||||
@@ -835,6 +854,28 @@ describe("device-pair /pair default setup code", () => {
|
||||
expect(text).toContain("Gateway: wss://gateway.example.test:18789");
|
||||
});
|
||||
|
||||
it("keeps secure setup limited for non-admin gateway callers", async () => {
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
args: "",
|
||||
commandBody: "/pair",
|
||||
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
|
||||
profile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
},
|
||||
});
|
||||
const text = requireText(result);
|
||||
expect(text).toContain("Access: limited");
|
||||
expect(text).not.toContain("Plaintext ws:// was limited for safety");
|
||||
});
|
||||
|
||||
it("allows loopback cleartext setup urls", async () => {
|
||||
const command = registerPairCommand({
|
||||
pluginConfig: {
|
||||
@@ -890,12 +931,20 @@ describe("device-pair /pair default setup code", () => {
|
||||
channel: "webchat",
|
||||
args: "",
|
||||
commandBody: "/pair",
|
||||
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
||||
expect(requireText(result)).toContain("Gateway: ws://192.168.1.20:18789");
|
||||
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
|
||||
profile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
},
|
||||
});
|
||||
const text = requireText(result);
|
||||
expect(text).toContain("Gateway: ws://192.168.1.20:18789");
|
||||
expect(text).toContain("Access: limited");
|
||||
expect(text).toContain("Plaintext ws:// was limited for safety");
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -37,6 +37,8 @@ type SetupPayload = {
|
||||
urls?: string[];
|
||||
bootstrapToken: string;
|
||||
expiresAtMs: number;
|
||||
access: "full" | "limited";
|
||||
accessDowngraded?: true;
|
||||
};
|
||||
|
||||
type ResolveUrlResult = {
|
||||
@@ -304,6 +306,17 @@ function validateMobilePairingUrl(url: string, source?: string): string | null {
|
||||
return describeSecureMobilePairingFix(source);
|
||||
}
|
||||
|
||||
function isFullAccessMobilePairingUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
parsed.protocol === "wss:" || (parsed.protocol === "ws:" && isLoopbackHost(parsed.hostname))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
|
||||
const nets = os.networkInterfaces();
|
||||
for (const entries of Object.values(nets)) {
|
||||
@@ -529,6 +542,7 @@ function formatSetupReply(payload: SetupPayload, authLabel: string): string {
|
||||
"",
|
||||
...formatGatewayLines(payload),
|
||||
`Auth: ${authLabel}`,
|
||||
...buildAccessLines(payload),
|
||||
...buildSecurityNoticeLines({
|
||||
kind: "setup code",
|
||||
expiresAtMs: payload.expiresAtMs,
|
||||
@@ -558,6 +572,7 @@ function buildQrInfoLines(params: {
|
||||
return [
|
||||
...formatGatewayLines(params.payload),
|
||||
`Auth: ${params.authLabel}`,
|
||||
...buildAccessLines(params.payload),
|
||||
...buildSecurityNoticeLines({
|
||||
kind: "QR code",
|
||||
expiresAtMs: params.expiresAtMs,
|
||||
@@ -578,6 +593,7 @@ function formatQrInfoMarkdown(params: {
|
||||
return [
|
||||
...formatGatewayLines(params.payload).map((line) => `- ${line}`),
|
||||
`- Auth: ${params.authLabel}`,
|
||||
...buildAccessLines(params.payload, true),
|
||||
...buildSecurityNoticeLines({
|
||||
kind: "QR code",
|
||||
expiresAtMs: params.expiresAtMs,
|
||||
@@ -618,17 +634,48 @@ function formatGatewayLines(payload: SetupPayload): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
async function issueSetupPayload(url: string, urls?: string[]): Promise<SetupPayload> {
|
||||
function buildAccessLines(payload: SetupPayload, markdown = false): string[] {
|
||||
const prefix = markdown ? "- " : "";
|
||||
return [
|
||||
`${prefix}Access: ${payload.access}`,
|
||||
...(payload.accessDowngraded
|
||||
? [
|
||||
`${prefix}Plaintext ws:// was limited for safety. Use wss:// or Tailscale Serve, then generate a new code for full access.`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
async function issueSetupPayload(params: {
|
||||
url: string;
|
||||
urls?: string[];
|
||||
allowFullAccess: boolean;
|
||||
}): Promise<SetupPayload> {
|
||||
const { issueDeviceBootstrapToken, PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await loadDevicePairApiModule();
|
||||
const hasPlaintextRoute = [...new Set([params.url, ...(params.urls ?? [])])].some(
|
||||
(url) => !isFullAccessMobilePairingUrl(url),
|
||||
);
|
||||
// Every advertised URL shares this bearer token. Admin handoff therefore
|
||||
// needs both an authorized issuer and TLS (or same-host loopback) everywhere.
|
||||
const fullAccess = params.allowFullAccess && !hasPlaintextRoute;
|
||||
const accessDowngraded = params.allowFullAccess && hasPlaintextRoute;
|
||||
const issuedBootstrap = await issueDeviceBootstrapToken({
|
||||
profile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
profile: fullAccess
|
||||
? {
|
||||
roles: [...PAIRING_SETUP_BOOTSTRAP_PROFILE.roles],
|
||||
scopes: ["operator.admin", ...PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes],
|
||||
purpose: "mobile-full",
|
||||
}
|
||||
: PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
return {
|
||||
url,
|
||||
...(urls ? { urls } : {}),
|
||||
url: params.url,
|
||||
...(params.urls ? { urls: params.urls } : {}),
|
||||
bootstrapToken: issuedBootstrap.token,
|
||||
expiresAtMs: issuedBootstrap.expiresAtMs,
|
||||
access: fullAccess ? "full" : "limited",
|
||||
...(accessDowngraded ? { accessDowngraded: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -796,7 +843,11 @@ export default definePluginEntry({
|
||||
}
|
||||
}
|
||||
|
||||
let payload = await issueSetupPayload(urlResult.url, urlResult.urls);
|
||||
let payload = await issueSetupPayload({
|
||||
url: urlResult.url,
|
||||
urls: urlResult.urls,
|
||||
allowFullAccess: authState.canIssueFullAccessSetup,
|
||||
});
|
||||
let setupCode = encodeSetupCode(payload);
|
||||
|
||||
const infoLines = buildQrInfoLines({
|
||||
@@ -842,7 +893,11 @@ export default definePluginEntry({
|
||||
`device-pair: QR image send failed channel=${channel}, falling back (${(err as Error)?.message ?? err})`,
|
||||
);
|
||||
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
|
||||
payload = await issueSetupPayload(urlResult.url, urlResult.urls);
|
||||
payload = await issueSetupPayload({
|
||||
url: urlResult.url,
|
||||
urls: urlResult.urls,
|
||||
allowFullAccess: authState.canIssueFullAccessSetup,
|
||||
});
|
||||
setupCode = encodeSetupCode(payload);
|
||||
} finally {
|
||||
if (qrFilePath) {
|
||||
@@ -864,7 +919,11 @@ export default definePluginEntry({
|
||||
`device-pair: webchat QR render failed, falling back (${(err as Error)?.message ?? err})`,
|
||||
);
|
||||
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
|
||||
payload = await issueSetupPayload(urlResult.url, urlResult.urls);
|
||||
payload = await issueSetupPayload({
|
||||
url: urlResult.url,
|
||||
urls: urlResult.urls,
|
||||
allowFullAccess: authState.canIssueFullAccessSetup,
|
||||
});
|
||||
return {
|
||||
text:
|
||||
"QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.\n\n" +
|
||||
@@ -902,7 +961,11 @@ export default definePluginEntry({
|
||||
normalizeOptionalString(ctx.from) ||
|
||||
normalizeOptionalString(ctx.to) ||
|
||||
"";
|
||||
const payload = await issueSetupPayload(urlResult.url, urlResult.urls);
|
||||
const payload = await issueSetupPayload({
|
||||
url: urlResult.url,
|
||||
urls: urlResult.urls,
|
||||
allowFullAccess: authState.canIssueFullAccessSetup,
|
||||
});
|
||||
|
||||
if (channel === "telegram" && target) {
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("device-pair pairing command auth", () => {
|
||||
isInternalGatewayCaller: false,
|
||||
isMissingPairingPrivilege: true,
|
||||
isMissingSetupHandoffPrivilege: true,
|
||||
canIssueFullAccessSetup: false,
|
||||
approvalCallerScopes: undefined,
|
||||
});
|
||||
});
|
||||
@@ -28,6 +29,7 @@ describe("device-pair pairing command auth", () => {
|
||||
isInternalGatewayCaller: false,
|
||||
isMissingPairingPrivilege: false,
|
||||
isMissingSetupHandoffPrivilege: false,
|
||||
canIssueFullAccessSetup: true,
|
||||
approvalCallerScopes: ["operator.pairing"],
|
||||
});
|
||||
});
|
||||
@@ -42,6 +44,7 @@ describe("device-pair pairing command auth", () => {
|
||||
isInternalGatewayCaller: true,
|
||||
isMissingPairingPrivilege: true,
|
||||
isMissingSetupHandoffPrivilege: true,
|
||||
canIssueFullAccessSetup: false,
|
||||
approvalCallerScopes: [],
|
||||
});
|
||||
});
|
||||
@@ -56,6 +59,7 @@ describe("device-pair pairing command auth", () => {
|
||||
isInternalGatewayCaller: true,
|
||||
isMissingPairingPrivilege: false,
|
||||
isMissingSetupHandoffPrivilege: true,
|
||||
canIssueFullAccessSetup: false,
|
||||
approvalCallerScopes: ["operator.write", "operator.pairing"],
|
||||
});
|
||||
expect(
|
||||
@@ -67,6 +71,7 @@ describe("device-pair pairing command auth", () => {
|
||||
isInternalGatewayCaller: true,
|
||||
isMissingPairingPrivilege: false,
|
||||
isMissingSetupHandoffPrivilege: false,
|
||||
canIssueFullAccessSetup: false,
|
||||
approvalCallerScopes: ["operator.write", "operator.pairing", "operator.talk.secrets"],
|
||||
});
|
||||
expect(
|
||||
@@ -78,6 +83,7 @@ describe("device-pair pairing command auth", () => {
|
||||
isInternalGatewayCaller: true,
|
||||
isMissingPairingPrivilege: false,
|
||||
isMissingSetupHandoffPrivilege: false,
|
||||
canIssueFullAccessSetup: true,
|
||||
approvalCallerScopes: ["operator.admin"],
|
||||
});
|
||||
});
|
||||
@@ -93,6 +99,7 @@ describe("device-pair pairing command auth", () => {
|
||||
isInternalGatewayCaller: true,
|
||||
isMissingPairingPrivilege: false,
|
||||
isMissingSetupHandoffPrivilege: true,
|
||||
canIssueFullAccessSetup: false,
|
||||
approvalCallerScopes: ["operator.write", "operator.pairing"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ type PairingCommandAuthState = {
|
||||
isInternalGatewayCaller: boolean;
|
||||
isMissingPairingPrivilege: boolean;
|
||||
isMissingSetupHandoffPrivilege: boolean;
|
||||
canIssueFullAccessSetup: boolean;
|
||||
approvalCallerScopes?: readonly string[];
|
||||
};
|
||||
|
||||
@@ -41,6 +42,7 @@ export function resolvePairingCommandAuthState(
|
||||
isInternalGatewayCaller,
|
||||
isMissingPairingPrivilege: !hasPairingPrivilege(approvalCallerScopes),
|
||||
isMissingSetupHandoffPrivilege: !hasSetupHandoffPrivilege(approvalCallerScopes),
|
||||
canIssueFullAccessSetup: approvalCallerScopes.includes(ADMIN_SCOPE),
|
||||
approvalCallerScopes,
|
||||
};
|
||||
}
|
||||
@@ -50,6 +52,7 @@ export function resolvePairingCommandAuthState(
|
||||
isInternalGatewayCaller,
|
||||
isMissingPairingPrivilege: false,
|
||||
isMissingSetupHandoffPrivilege: false,
|
||||
canIssueFullAccessSetup: true,
|
||||
approvalCallerScopes: COMMAND_OWNER_PAIRING_SCOPES,
|
||||
};
|
||||
}
|
||||
@@ -58,6 +61,7 @@ export function resolvePairingCommandAuthState(
|
||||
isInternalGatewayCaller,
|
||||
isMissingPairingPrivilege: true,
|
||||
isMissingSetupHandoffPrivilege: true,
|
||||
canIssueFullAccessSetup: false,
|
||||
approvalCallerScopes: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,18 +99,19 @@ const SetupCodeQrDataUrlSchema = Type.String({
|
||||
/**
|
||||
* Generates a device-pairing setup code (and optional QR) so a mobile/companion
|
||||
* client can scan it and connect to this gateway. The embedded setup code mints
|
||||
* a short-lived bootstrap token that hands off broad operator scopes
|
||||
* (read/write/approvals/talk.secrets), so this method requires operator.admin
|
||||
* a short-lived bootstrap token that defaults to full native-mobile operator
|
||||
* access, so this method requires operator.admin
|
||||
* (enforced by the core method descriptor's method-scope policy, not the handler)
|
||||
* and is not advertised. `bootstrapProfile: "node"` narrows the handoff to a
|
||||
* node role with no operator scopes for companion devices such as watchOS.
|
||||
* and is not advertised. `bootstrapProfile: "limited"` omits operator.admin;
|
||||
* `bootstrapProfile: "node"` narrows the handoff to a node role with no operator
|
||||
* scopes for companion devices such as watchOS.
|
||||
*/
|
||||
export const DevicePairSetupCodeParamsSchema = Type.Object(
|
||||
{
|
||||
publicUrl: Type.Optional(NonEmptyString),
|
||||
preferRemoteUrl: Type.Optional(Type.Boolean()),
|
||||
includeQr: Type.Optional(Type.Boolean()),
|
||||
bootstrapProfile: Type.Optional(Type.Literal("node")),
|
||||
bootstrapProfile: Type.Optional(Type.String({ enum: ["limited", "node"] })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -118,6 +119,8 @@ export const DevicePairSetupCodeParamsSchema = Type.Object(
|
||||
/**
|
||||
* Setup code plus non-secret connection metadata. `auth` is a label only
|
||||
* ("token" | "password"); the gateway credential itself is never returned.
|
||||
* `accessDowngraded` reports the plaintext-LAN safety fallback from full to
|
||||
* limited access so the presenting client can explain how to upgrade.
|
||||
*/
|
||||
export const DevicePairSetupCodeResultSchema = Type.Object(
|
||||
{
|
||||
@@ -129,6 +132,10 @@ export const DevicePairSetupCodeResultSchema = Type.Object(
|
||||
),
|
||||
auth: Type.Union([Type.Literal("token"), Type.Literal("password")]),
|
||||
urlSource: NonEmptyString,
|
||||
access: Type.Optional(
|
||||
Type.Union([Type.Literal("full"), Type.Literal("limited"), Type.Literal("node")]),
|
||||
),
|
||||
accessDowngraded: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
+59
-4
@@ -2,6 +2,10 @@
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { encodePairingSetupCode } from "../pairing/setup-code.js";
|
||||
import {
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -12,6 +16,10 @@ const mocks = vi.hoisted(() => ({
|
||||
diagnostics: [] as string[],
|
||||
})),
|
||||
renderTerminal: vi.fn(async () => "ASCII-QR"),
|
||||
issueDeviceBootstrapToken: vi.fn(async () => ({
|
||||
token: "bootstrap-123",
|
||||
expiresAtMs: 123,
|
||||
})),
|
||||
}));
|
||||
const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture();
|
||||
const runtimeLog = runtime.log;
|
||||
@@ -36,15 +44,13 @@ vi.mock("./command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
||||
}));
|
||||
vi.mock("../infra/device-bootstrap.js", () => ({
|
||||
issueDeviceBootstrapToken: vi.fn(async () => ({
|
||||
token: "bootstrap-123",
|
||||
expiresAtMs: 123,
|
||||
})),
|
||||
issueDeviceBootstrapToken: mocks.issueDeviceBootstrapToken,
|
||||
}));
|
||||
const loadConfig = mocks.loadConfig;
|
||||
const runCommandWithTimeout = mocks.runCommandWithTimeout;
|
||||
const resolveCommandSecretRefsViaGateway = mocks.resolveCommandSecretRefsViaGateway;
|
||||
const renderTerminal = mocks.renderTerminal;
|
||||
const issueDeviceBootstrapToken = mocks.issueDeviceBootstrapToken;
|
||||
|
||||
const { registerQrCli } = await import("./qr-cli.js");
|
||||
|
||||
@@ -143,6 +149,8 @@ describe("registerQrCli", () => {
|
||||
gatewayUrl?: string;
|
||||
auth?: string;
|
||||
urlSource?: string;
|
||||
access?: "full" | "limited";
|
||||
accessDowngraded?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +166,12 @@ describe("registerQrCli", () => {
|
||||
expectLoggedSetupCode("ws://127.0.0.1:18789");
|
||||
}
|
||||
|
||||
function expectLimitedTransportWarning() {
|
||||
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
|
||||
expect(output).toContain("setup code was limited for safety");
|
||||
expect(output).toContain("Use wss:// or Tailscale Serve");
|
||||
}
|
||||
|
||||
function mockTailscaleStatusLookup() {
|
||||
runCommandWithTimeout.mockResolvedValue({
|
||||
code: 0,
|
||||
@@ -198,6 +212,35 @@ describe("registerQrCli", () => {
|
||||
expect(runtime.log).toHaveBeenCalledWith(expected);
|
||||
expect(renderTerminal).not.toHaveBeenCalled();
|
||||
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
||||
expect(issueDeviceBootstrapToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the bounded bootstrap profile with --limited", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "127.0.0.1",
|
||||
auth: { mode: "token", token: "tok" },
|
||||
},
|
||||
});
|
||||
|
||||
await runQr(["--setup-code-only", "--limited"]);
|
||||
|
||||
expect(issueDeviceBootstrapToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders ASCII QR by default", async () => {
|
||||
@@ -216,6 +259,8 @@ describe("registerQrCli", () => {
|
||||
expect(output).toContain("Pairing QR");
|
||||
expect(output).toContain("ASCII-QR");
|
||||
expect(output).toContain("Gateway:");
|
||||
expect(output).toContain("Access:");
|
||||
expect(output).toContain("full");
|
||||
expect(output).toContain("openclaw devices approve <requestId>");
|
||||
});
|
||||
|
||||
@@ -247,6 +292,10 @@ describe("registerQrCli", () => {
|
||||
await runQr(["--setup-code-only"]);
|
||||
|
||||
expectLoggedSetupCode("ws://192.168.1.8:18789");
|
||||
expect(issueDeviceBootstrapToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profile: PAIRING_SETUP_BOOTSTRAP_PROFILE }),
|
||||
);
|
||||
expectLimitedTransportWarning();
|
||||
});
|
||||
|
||||
it("allows android emulator cleartext override urls", async () => {
|
||||
@@ -260,6 +309,10 @@ describe("registerQrCli", () => {
|
||||
await runQr(["--setup-code-only", "--url", "ws://10.0.2.2:18789"]);
|
||||
|
||||
expectLoggedSetupCode("ws://10.0.2.2:18789");
|
||||
expect(issueDeviceBootstrapToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profile: PAIRING_SETUP_BOOTSTRAP_PROFILE }),
|
||||
);
|
||||
expectLimitedTransportWarning();
|
||||
});
|
||||
|
||||
it("rejects invalid override urls before printing setup codes", async () => {
|
||||
@@ -484,6 +537,8 @@ describe("registerQrCli", () => {
|
||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
||||
expect(payload.auth).toBe("token");
|
||||
expect(payload.urlSource).toBe("gateway.remote.url");
|
||||
expect(payload.access).toBe("full");
|
||||
expect(payload.accessDowngraded).toBeUndefined();
|
||||
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { renderQrTerminal } from "../media/qr-terminal.ts";
|
||||
import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js";
|
||||
import { getQrRemoteCommandSecretTargetIds } from "./command-secret-targets.js";
|
||||
|
||||
@@ -23,8 +24,12 @@ type QrCliOptions = {
|
||||
publicUrl?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
limited?: boolean;
|
||||
};
|
||||
|
||||
const LIMITED_TRANSPORT_WARNING =
|
||||
"This Gateway URL uses plaintext ws://, so the setup code was limited for safety. Use wss:// or Tailscale Serve, then generate a new code for full access.";
|
||||
|
||||
function renderQrAscii(data: string): Promise<string> {
|
||||
return renderQrTerminal(data);
|
||||
}
|
||||
@@ -108,6 +113,7 @@ export function registerQrCli(program: Command) {
|
||||
.option("--public-url <url>", "Override gateway public URL used in the setup payload")
|
||||
.option("--token <token>", "Override gateway token for setup payload")
|
||||
.option("--password <password>", "Override gateway password for setup payload")
|
||||
.option("--limited", "Pair with limited operator access (omit operator.admin)", false)
|
||||
.option("--setup-code-only", "Print only the setup code", false)
|
||||
.option("--no-ascii", "Skip ASCII QR rendering")
|
||||
.option("--json", "Output JSON", false)
|
||||
@@ -199,6 +205,7 @@ export function registerQrCli(program: Command) {
|
||||
const resolved = await resolvePairingSetupFromConfig(cfg, {
|
||||
publicUrl,
|
||||
preferRemoteUrl: wantsRemote,
|
||||
...(opts.limited ? { bootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE } : {}),
|
||||
runCommandWithTimeout: async (argv, runOpts) =>
|
||||
await runCommandWithTimeout(argv, {
|
||||
timeoutMs: runOpts.timeoutMs,
|
||||
@@ -212,6 +219,9 @@ export function registerQrCli(program: Command) {
|
||||
const setupCode = encodePairingSetupCode(resolved.payload);
|
||||
|
||||
if (opts.setupCodeOnly) {
|
||||
if (resolved.accessDowngraded) {
|
||||
defaultRuntime.error(theme.warn(LIMITED_TRANSPORT_WARNING));
|
||||
}
|
||||
defaultRuntime.log(setupCode);
|
||||
return;
|
||||
}
|
||||
@@ -223,6 +233,8 @@ export function registerQrCli(program: Command) {
|
||||
...(resolved.payload.urls ? { gatewayUrls: resolved.payload.urls } : {}),
|
||||
auth: resolved.authLabel,
|
||||
urlSource: resolved.urlSource,
|
||||
access: resolved.access,
|
||||
...(resolved.accessDowngraded ? { accessDowngraded: true } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -244,6 +256,8 @@ export function registerQrCli(program: Command) {
|
||||
...(resolved.payload.urls?.slice(1).map((url) => `${theme.muted("Fallback:")} ${url}`) ??
|
||||
[]),
|
||||
`${theme.muted("Auth:")} ${resolved.authLabel}`,
|
||||
`${theme.muted("Access:")} ${resolved.access}`,
|
||||
...(resolved.accessDowngraded ? [theme.warn(LIMITED_TRANSPORT_WARNING)] : []),
|
||||
`${theme.muted("Source:")} ${resolved.urlSource}`,
|
||||
"",
|
||||
"Approve after scan with:",
|
||||
|
||||
@@ -57,6 +57,8 @@ const okResolution = {
|
||||
},
|
||||
authLabel: "token" as const,
|
||||
urlSource: "remote",
|
||||
access: "full" as const,
|
||||
accessDowngraded: false,
|
||||
};
|
||||
|
||||
describe("device.pair.setupCode", () => {
|
||||
@@ -92,11 +94,32 @@ describe("device.pair.setupCode", () => {
|
||||
gatewayUrls: ["wss://gw.example:8443", "ws://192.168.1.20:18789"],
|
||||
auth: "token",
|
||||
urlSource: "remote",
|
||||
access: "full",
|
||||
});
|
||||
// The bootstrap token only lives inside the (opaque) setup code, never as a field.
|
||||
expect(JSON.stringify(payload)).not.toContain("boot-123");
|
||||
});
|
||||
|
||||
it("reports when plaintext transport limits a requested full-access code", async () => {
|
||||
mocks.resolvePairingSetupFromConfig.mockResolvedValue({
|
||||
...okResolution,
|
||||
access: "limited",
|
||||
accessDowngraded: true,
|
||||
});
|
||||
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
|
||||
|
||||
const { options, respond } = createOptions({ includeQr: false });
|
||||
await expectDefined(
|
||||
devicePairSetupHandlers["device.pair.setupCode"],
|
||||
'devicePairSetupHandlers["device.pair.setupCode"] test invariant',
|
||||
)(options);
|
||||
|
||||
expect(respond.mock.calls[0]?.[1]).toMatchObject({
|
||||
access: "limited",
|
||||
accessDowngraded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the configured device-pair public URL fallback", async () => {
|
||||
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
|
||||
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
|
||||
@@ -205,6 +228,32 @@ describe("device.pair.setupCode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requests the limited mobile bootstrap profile when selected", async () => {
|
||||
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
|
||||
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
|
||||
|
||||
const { options } = createOptions({ includeQr: false, bootstrapProfile: "limited" });
|
||||
await expectDefined(
|
||||
devicePairSetupHandlers["device.pair.setupCode"],
|
||||
'devicePairSetupHandlers["device.pair.setupCode"] test invariant',
|
||||
)(options);
|
||||
|
||||
expect(mocks.resolvePairingSetupFromConfig).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
bootstrapProfile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits an oversized QR but still returns the setup code", async () => {
|
||||
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
|
||||
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
|
||||
|
||||
@@ -11,7 +11,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { renderQrPngDataUrl } from "../../media/qr-image.js";
|
||||
import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "../../pairing/setup-code.js";
|
||||
import { runCommandWithTimeout } from "../../process/exec.js";
|
||||
import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../../shared/device-bootstrap-profile.js";
|
||||
import {
|
||||
NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
} from "../../shared/device-bootstrap-profile.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
@@ -50,8 +53,13 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
|
||||
env: process.env,
|
||||
publicUrl,
|
||||
preferRemoteUrl: params.preferRemoteUrl === true,
|
||||
...(params.bootstrapProfile === "node"
|
||||
? { bootstrapProfile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE }
|
||||
...(params.bootstrapProfile
|
||||
? {
|
||||
bootstrapProfile:
|
||||
params.bootstrapProfile === "node"
|
||||
? NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE
|
||||
: PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
}
|
||||
: {}),
|
||||
// Lets Tailscale serve/funnel URLs resolve, mirroring the `openclaw qr` CLI.
|
||||
runCommandWithTimeout: async (argv, runOpts) =>
|
||||
@@ -80,6 +88,8 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
|
||||
// Label only — never the raw gateway token/password.
|
||||
auth: resolved.authLabel,
|
||||
urlSource: requestPublicUrl ? "request.publicUrl" : resolved.urlSource,
|
||||
access: resolved.access,
|
||||
...(resolved.accessDowngraded ? { accessDowngraded: true } : {}),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -110,13 +110,20 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
mode: "node";
|
||||
deviceFamily: string;
|
||||
};
|
||||
limited?: boolean;
|
||||
}) => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(params.identityPrefix);
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: params.limited
|
||||
? PAIRING_SETUP_BOOTSTRAP_PROFILE
|
||||
: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
@@ -1128,10 +1135,12 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
}
|
||||
});
|
||||
|
||||
test("qr setup code returns node token plus bounded operator handoff", async () => {
|
||||
test("qr setup code returns node token plus full operator handoff", async () => {
|
||||
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
|
||||
await import("../infra/device-bootstrap.js");
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
@@ -1148,7 +1157,9 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
@@ -1189,12 +1200,13 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
throw new Error("expected handed-off operator device token");
|
||||
}
|
||||
expect(operatorHandoff?.scopes).toEqual([
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]);
|
||||
expect(operatorHandoff?.scopes).not.toContain("operator.admin");
|
||||
expect(operatorHandoff?.scopes).toContain("operator.admin");
|
||||
|
||||
const pendingAfterInitial = await listDevicePairing();
|
||||
const pendingForDevice = pendingAfterInitial.pending.filter(
|
||||
@@ -1210,6 +1222,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(["node", "operator"]);
|
||||
expect(paired?.approvedScopes).toEqual([
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
@@ -1219,6 +1232,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
expect(paired?.tokens?.node?.scopes).toEqual([]);
|
||||
expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual([
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
@@ -1276,6 +1290,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
token: issuedOperatorToken,
|
||||
role: "operator",
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
@@ -1290,7 +1305,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
@@ -1298,7 +1313,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||
).resolves.toEqual({ ok: true });
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
@@ -1357,12 +1372,13 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
);
|
||||
expect(operatorHandoff?.deviceToken).toBeTruthy();
|
||||
expect(operatorHandoff?.scopes).toEqual([
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]);
|
||||
expect(operatorHandoff?.scopes).not.toContain("operator.admin");
|
||||
expect(operatorHandoff?.scopes).toContain("operator.admin");
|
||||
|
||||
const pendingAfterInitial = await listDevicePairing();
|
||||
expect(
|
||||
@@ -1371,6 +1387,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(["node", "operator"]);
|
||||
expect(paired?.approvedScopes).toEqual([
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
@@ -1379,6 +1396,96 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
},
|
||||
);
|
||||
|
||||
test("limited qr setup keeps the previous bounded operator handoff", async () => {
|
||||
const { identity, initial } = await connectSetupCodeBootstrapNode({
|
||||
identityPrefix: "openclaw-bootstrap-limited-node-",
|
||||
client: {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.7.13",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node",
|
||||
deviceFamily: "iPhone",
|
||||
},
|
||||
limited: true,
|
||||
});
|
||||
expect(initial.ok).toBe(true);
|
||||
const payload = initial.payload as
|
||||
| {
|
||||
auth?: {
|
||||
deviceTokens?: Array<{ deviceToken?: string; role?: string; scopes?: string[] }>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const operatorHandoff = payload?.auth?.deviceTokens?.find((entry) => entry.role === "operator");
|
||||
const operatorToken = operatorHandoff?.deviceToken;
|
||||
if (!operatorToken) {
|
||||
throw new Error("expected handed-off limited operator device token");
|
||||
}
|
||||
expect(operatorHandoff?.scopes).toEqual([
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]);
|
||||
expect(operatorHandoff?.scopes).not.toContain("operator.admin");
|
||||
|
||||
const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js");
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.approvedScopes).not.toContain("operator.admin");
|
||||
expect(paired?.tokens?.operator?.scopes).not.toContain("operator.admin");
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: operatorToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: operatorToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||
});
|
||||
|
||||
test("full qr setup upgrades an existing limited mobile pairing", async () => {
|
||||
const identityPrefix = "openclaw-bootstrap-limited-upgrade-node-";
|
||||
const client = {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.7.13",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node" as const,
|
||||
deviceFamily: "iPhone",
|
||||
};
|
||||
const limited = await connectSetupCodeBootstrapNode({
|
||||
identityPrefix,
|
||||
client,
|
||||
limited: true,
|
||||
});
|
||||
const upgraded = await connectSetupCodeBootstrapNode({ identityPrefix, client });
|
||||
expect(upgraded.identity.deviceId).toBe(limited.identity.deviceId);
|
||||
expect(upgraded.initial.ok).toBe(true);
|
||||
|
||||
const payload = upgraded.initial.payload as
|
||||
| {
|
||||
auth?: {
|
||||
deviceTokens?: Array<{ role?: string; scopes?: string[] }>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(
|
||||
payload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
|
||||
).toContain("operator.admin");
|
||||
|
||||
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
||||
const paired = await getPairedDevice(upgraded.identity.deviceId);
|
||||
expect(paired?.approvedScopes).toContain("operator.admin");
|
||||
expect(paired?.tokens?.operator?.scopes).toContain("operator.admin");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "mobile client id with mismatched platform metadata",
|
||||
@@ -1431,13 +1538,13 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
},
|
||||
);
|
||||
|
||||
test("qr bootstrap retry keeps bounded operator handoff after paired approval", async () => {
|
||||
test("qr bootstrap retry keeps full operator handoff after paired approval", async () => {
|
||||
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
|
||||
await import("../infra/device-bootstrap.js");
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { approveBootstrapDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await import("../shared/device-bootstrap-profile.js");
|
||||
const { server, port, prevToken } = await startControlUiServer("secret");
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
@@ -1452,14 +1559,22 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
});
|
||||
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const pending = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey,
|
||||
role: "node",
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
clientId: client.id,
|
||||
clientMode: client.mode,
|
||||
displayName: client.id,
|
||||
@@ -1469,7 +1584,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
});
|
||||
await approveBootstrapDevicePairing(
|
||||
pending.request.requestId,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
);
|
||||
|
||||
const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
@@ -1496,12 +1611,13 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
);
|
||||
expect(operatorHandoff?.deviceToken).toBeTruthy();
|
||||
expect(operatorHandoff?.scopes).toEqual([
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]);
|
||||
expect(operatorHandoff?.scopes).not.toContain("operator.admin");
|
||||
expect(operatorHandoff?.scopes).toContain("operator.admin");
|
||||
wsRetry.close();
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -3,15 +3,17 @@ import {
|
||||
GATEWAY_CLIENT_IDS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
} from "../../../../packages/gateway-protocol/src/client-info.js";
|
||||
import { hasEffectivePairedDeviceRole, type PairedDevice } from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
type DeviceBootstrapProfile,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
|
||||
|
||||
export function resolvePairedAccessScopes(
|
||||
device: { approvedScopes?: unknown; scopes?: unknown } | null | undefined,
|
||||
device: Pick<PairedDevice, "approvedScopes" | "scopes"> | null | undefined,
|
||||
): string[] {
|
||||
const scopes = Array.isArray(device?.approvedScopes)
|
||||
? device.approvedScopes
|
||||
@@ -29,10 +31,10 @@ export function isSetupCodeMobileBootstrapClient(client: {
|
||||
const platform = normalizeDeviceMetadataForAuth(client.platform);
|
||||
const deviceFamily = normalizeDeviceMetadataForAuth(client.deviceFamily);
|
||||
if (client.id === GATEWAY_CLIENT_IDS.ANDROID_APP) {
|
||||
return /^android(?:\s|$)/.test(platform) && deviceFamily === "android";
|
||||
return /^android(?:\s|$)/u.test(platform) && deviceFamily === "android";
|
||||
}
|
||||
if (client.id === GATEWAY_CLIENT_IDS.IOS_APP) {
|
||||
return /^(?:ios|ipados)(?:\s|$)/.test(platform) && /^(?:iphone|ipad|ios)$/.test(deviceFamily);
|
||||
return /^(?:ios|ipados)(?:\s|$)/u.test(platform) && /^(?:iphone|ipad|ios)$/u.test(deviceFamily);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -62,6 +64,71 @@ export function isControlUiOperatorBootstrapProfile(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function isMobileNodeBootstrapConnect(params: {
|
||||
role: string;
|
||||
scopes: readonly string[];
|
||||
isControlUi: boolean;
|
||||
isBrowserOperatorUi: boolean;
|
||||
isWebchat: boolean;
|
||||
clientMode?: string;
|
||||
}): boolean {
|
||||
return (
|
||||
params.role === "node" &&
|
||||
params.scopes.length === 0 &&
|
||||
!params.isControlUi &&
|
||||
!params.isBrowserOperatorUi &&
|
||||
!params.isWebchat &&
|
||||
params.clientMode === GATEWAY_CLIENT_MODES.NODE
|
||||
);
|
||||
}
|
||||
|
||||
function pairedDeviceAllowsBootstrapRole(params: {
|
||||
device: PairedDevice;
|
||||
profile: DeviceBootstrapProfile;
|
||||
role: string;
|
||||
}): boolean {
|
||||
return (
|
||||
hasEffectivePairedDeviceRole(params.device, params.role) &&
|
||||
roleScopesAllow({
|
||||
role: params.role,
|
||||
requestedScopes: resolveBootstrapProfileScopesForRole(
|
||||
params.role,
|
||||
params.profile.scopes,
|
||||
params.profile.purpose,
|
||||
),
|
||||
allowedScopes: resolvePairedAccessScopes(params.device),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function pairedDeviceAllowsBootstrapProfile(params: {
|
||||
device: PairedDevice | null | undefined;
|
||||
devicePublicKey: string;
|
||||
profile: DeviceBootstrapProfile;
|
||||
}): boolean {
|
||||
const device = params.device;
|
||||
return Boolean(
|
||||
device &&
|
||||
device.publicKey === params.devicePublicKey &&
|
||||
params.profile.roles.every((role) =>
|
||||
pairedDeviceAllowsBootstrapRole({ device, profile: params.profile, role }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function pairedDeviceAllowsBootstrapOperator(params: {
|
||||
device: PairedDevice | null | undefined;
|
||||
devicePublicKey: string;
|
||||
profile: DeviceBootstrapProfile;
|
||||
}): boolean {
|
||||
const device = params.device;
|
||||
return Boolean(
|
||||
device &&
|
||||
device.publicKey === params.devicePublicKey &&
|
||||
pairedDeviceAllowsBootstrapRole({ device, profile: params.profile, role: "operator" }),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePinnedClientMetadata(params: {
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
normalizeSortedUniqueTrimmedStringList,
|
||||
uniqueStrings,
|
||||
} from "@openclaw/normalization-core/string-normalization";
|
||||
import { GATEWAY_CLIENT_MODES } from "../../../../packages/gateway-protocol/src/client-info.js";
|
||||
import {
|
||||
buildPairingConnectCloseReason,
|
||||
buildPairingConnectErrorDetails,
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
requestDevicePairing,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
isPairingSetupBootstrapProfile,
|
||||
isMobilePairingSetupBootstrapProfile,
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
resolveBootstrapProfileScopesForRoles,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
@@ -32,7 +31,9 @@ import { shouldAutoApproveNodePairingFromTrustedCidrs } from "../../node-pairing
|
||||
import { truncateCloseReason } from "../close-reason.js";
|
||||
import {
|
||||
isControlUiOperatorBootstrapProfile,
|
||||
isMobileNodeBootstrapConnect,
|
||||
isSetupCodeMobileBootstrapClient,
|
||||
pairedDeviceAllowsBootstrapProfile,
|
||||
resolvePairedAccessScopes,
|
||||
} from "./connect-device-metadata.js";
|
||||
import { issueGatewayConnectDeviceTokens } from "./connect-device-tokens.js";
|
||||
@@ -155,18 +156,25 @@ export async function authorizeGatewayConnectDevice(
|
||||
reportedClientIp,
|
||||
autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs,
|
||||
});
|
||||
const isSetupCodeMobileNodeConnect = isMobileNodeBootstrapConnect({
|
||||
role,
|
||||
scopes,
|
||||
isControlUi,
|
||||
isBrowserOperatorUi,
|
||||
isWebchat,
|
||||
clientMode: connectParams.client.mode,
|
||||
});
|
||||
const allowBoundBootstrapProfileLookup =
|
||||
(reason === "not-paired" &&
|
||||
!existingPairedDevice &&
|
||||
(isSetupCodeMobileNodeConnect || (isControlUi && role === "operator"))) ||
|
||||
(reason === "scope-upgrade" &&
|
||||
Boolean(existingPairedDevice) &&
|
||||
isSetupCodeMobileNodeConnect);
|
||||
const boundBootstrapProfile =
|
||||
authMethod === "bootstrap-token" &&
|
||||
bootstrapTokenCandidate &&
|
||||
reason === "not-paired" &&
|
||||
!existingPairedDevice &&
|
||||
((role === "node" &&
|
||||
scopes.length === 0 &&
|
||||
!isControlUi &&
|
||||
!isBrowserOperatorUi &&
|
||||
!isWebchat &&
|
||||
connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE) ||
|
||||
(isControlUi && role === "operator"))
|
||||
allowBoundBootstrapProfileLookup
|
||||
? await getBoundDeviceBootstrapProfile({
|
||||
token: bootstrapTokenCandidate,
|
||||
deviceId: device.id,
|
||||
@@ -175,13 +183,8 @@ export async function authorizeGatewayConnectDevice(
|
||||
: null;
|
||||
const allowSetupCodeMobileBootstrapPairing =
|
||||
boundBootstrapProfile !== null &&
|
||||
isPairingSetupBootstrapProfile(boundBootstrapProfile) &&
|
||||
role === "node" &&
|
||||
scopes.length === 0 &&
|
||||
!isControlUi &&
|
||||
!isBrowserOperatorUi &&
|
||||
!isWebchat &&
|
||||
connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE &&
|
||||
isMobilePairingSetupBootstrapProfile(boundBootstrapProfile) &&
|
||||
isSetupCodeMobileNodeConnect &&
|
||||
isSetupCodeMobileBootstrapClient(connectParams.client);
|
||||
const setupCodeMobileBootstrapProfile = allowSetupCodeMobileBootstrapPairing
|
||||
? boundBootstrapProfile
|
||||
@@ -196,7 +199,8 @@ export async function authorizeGatewayConnectDevice(
|
||||
// This is the native QR/setup-code onboarding seam. Mobile clients
|
||||
// must prove their canonical client id and platform/family metadata
|
||||
// agree before the Gateway can skip owner approval and hand off the
|
||||
// bounded operator token below. Admin/pairing still require an explicit owner flow.
|
||||
// selected operator profile below. Full mobile setup includes admin;
|
||||
// limited setup retains the previous bounded operator scope set.
|
||||
const bootstrapPairingRoles = setupCodeMobileBootstrapProfile
|
||||
? uniqueStrings([role, ...setupCodeMobileBootstrapProfile.roles])
|
||||
: controlUiOperatorBootstrapProfile
|
||||
@@ -206,11 +210,13 @@ export async function authorizeGatewayConnectDevice(
|
||||
? resolveBootstrapProfileScopesForRoles(
|
||||
bootstrapPairingRoles ?? [],
|
||||
setupCodeMobileBootstrapProfile.scopes,
|
||||
setupCodeMobileBootstrapProfile.purpose,
|
||||
)
|
||||
: controlUiOperatorBootstrapProfile
|
||||
? resolveBootstrapProfileScopesForRole(
|
||||
"operator",
|
||||
controlUiOperatorBootstrapProfile.scopes,
|
||||
controlUiOperatorBootstrapProfile.purpose,
|
||||
)
|
||||
: undefined;
|
||||
const bootstrapApprovalProfile =
|
||||
@@ -226,7 +232,7 @@ export async function authorizeGatewayConnectDevice(
|
||||
}
|
||||
: {}),
|
||||
silent:
|
||||
reason === "scope-upgrade"
|
||||
reason === "scope-upgrade" && !allowSetupCodeMobileBootstrapPairing
|
||||
? false
|
||||
: allowSilentLocalPairing ||
|
||||
allowSilentTrustedCidrsNodePairing ||
|
||||
@@ -312,9 +318,14 @@ export async function authorizeGatewayConnectDevice(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resolvedByConcurrentApproval = pairingStateAllowsRequestedAccess(
|
||||
await getPairedDevice(device.id),
|
||||
);
|
||||
const pairedAfterConcurrentApproval = await getPairedDevice(device.id);
|
||||
resolvedByConcurrentApproval = bootstrapApprovalProfile
|
||||
? pairedDeviceAllowsBootstrapProfile({
|
||||
device: pairedAfterConcurrentApproval,
|
||||
devicePublicKey,
|
||||
profile: bootstrapApprovalProfile,
|
||||
})
|
||||
: pairingStateAllowsRequestedAccess(pairedAfterConcurrentApproval);
|
||||
let requestStillPending = false;
|
||||
if (!resolvedByConcurrentApproval) {
|
||||
recoveryRequestId = await resolveLivePendingRequestId();
|
||||
|
||||
@@ -56,11 +56,15 @@ export async function issueGatewayConnectDeviceTokens(params: {
|
||||
continue;
|
||||
}
|
||||
// Extra hello-ok handoff tokens are only emitted for the approved
|
||||
// setup-code profile. Operator scopes are filtered through the
|
||||
// documented allowlist so QR bootstrap cannot grant admin/pairing.
|
||||
// setup-code profile. Operator scopes are filtered through the closed
|
||||
// mobile allowlist selected when the setup code was issued.
|
||||
const bootstrapRoleScopes =
|
||||
bootstrapRole === "operator"
|
||||
? resolveBootstrapProfileScopesForRole(bootstrapRole, handoffBootstrapProfile.scopes)
|
||||
? resolveBootstrapProfileScopesForRole(
|
||||
bootstrapRole,
|
||||
handoffBootstrapProfile.scopes,
|
||||
handoffBootstrapProfile.purpose,
|
||||
)
|
||||
: [];
|
||||
const extraToken = await ensureDeviceToken({
|
||||
deviceId: device.id,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Gateway WebSocket paired-device connects enforce pinned metadata and approved access.
|
||||
import { GATEWAY_CLIENT_MODES } from "../../../../packages/gateway-protocol/src/client-info.js";
|
||||
import { getBoundDeviceBootstrapProfile } from "../../../infra/device-bootstrap.js";
|
||||
import {
|
||||
getPairedDevice,
|
||||
@@ -7,12 +6,15 @@ import {
|
||||
updatePairedDeviceMetadata,
|
||||
} from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
isPairingSetupBootstrapProfile,
|
||||
isMobilePairingSetupBootstrapProfile,
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import {
|
||||
isMobileNodeBootstrapConnect,
|
||||
isSetupCodeMobileBootstrapClient,
|
||||
pairedDeviceAllowsBootstrapOperator,
|
||||
resolvePairedAccessScopes,
|
||||
resolvePinnedClientMetadata,
|
||||
} from "./connect-device-metadata.js";
|
||||
@@ -124,13 +126,14 @@ export async function authorizeExistingGatewayDevice(params: {
|
||||
const retryBootstrapHandoffProfile =
|
||||
authMethod === "bootstrap-token" &&
|
||||
bootstrapTokenCandidate &&
|
||||
role === "node" &&
|
||||
scopes.length === 0 &&
|
||||
!isControlUi &&
|
||||
!isBrowserOperatorUi &&
|
||||
!isWebchat &&
|
||||
connectParams.client.mode === GATEWAY_CLIENT_MODES.NODE &&
|
||||
pairedRoles.includes("operator") &&
|
||||
isMobileNodeBootstrapConnect({
|
||||
role,
|
||||
scopes,
|
||||
isControlUi,
|
||||
isBrowserOperatorUi,
|
||||
isWebchat,
|
||||
clientMode: connectParams.client.mode,
|
||||
}) &&
|
||||
device
|
||||
? await getBoundDeviceBootstrapProfile({
|
||||
token: bootstrapTokenCandidate,
|
||||
@@ -142,19 +145,37 @@ export async function authorizeExistingGatewayDevice(params: {
|
||||
const retryBootstrapOperatorScopes = resolveBootstrapProfileScopesForRole(
|
||||
"operator",
|
||||
retryBootstrapHandoffProfile.scopes,
|
||||
retryBootstrapHandoffProfile.purpose,
|
||||
);
|
||||
if (
|
||||
isPairingSetupBootstrapProfile(retryBootstrapHandoffProfile) &&
|
||||
roleScopesAllow({
|
||||
role: "operator",
|
||||
requestedScopes: retryBootstrapOperatorScopes,
|
||||
allowedScopes: pairedScopes,
|
||||
})
|
||||
isMobilePairingSetupBootstrapProfile(retryBootstrapHandoffProfile) &&
|
||||
isSetupCodeMobileBootstrapClient(connectParams.client)
|
||||
) {
|
||||
// If the first QR bootstrap hello-ok failed to reach mobile, the
|
||||
// bootstrap token is restored while the paired device already has
|
||||
// node+operator grants. Preserve the same bounded handoff on retry.
|
||||
handoffBootstrapProfile = retryBootstrapHandoffProfile;
|
||||
const pairedAllowsHandoff =
|
||||
pairedRoles.includes("operator") &&
|
||||
roleScopesAllow({
|
||||
role: "operator",
|
||||
requestedScopes: retryBootstrapOperatorScopes,
|
||||
allowedScopes: pairedScopes,
|
||||
});
|
||||
if (!pairedAllowsHandoff) {
|
||||
params.logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
||||
if (!(await requirePairing("scope-upgrade", paired))) {
|
||||
return { ok: false, handoffBootstrapProfile };
|
||||
}
|
||||
}
|
||||
const pairedAfterBootstrapUpgrade = device ? await getPairedDevice(device.id) : null;
|
||||
if (
|
||||
pairedDeviceAllowsBootstrapOperator({
|
||||
device: pairedAfterBootstrapUpgrade,
|
||||
devicePublicKey,
|
||||
profile: retryBootstrapHandoffProfile,
|
||||
})
|
||||
) {
|
||||
// The setup code is the owner-approved upgrade artifact. Reuse the
|
||||
// same handoff after retrying or promoting an existing mobile pairing.
|
||||
handoffBootstrapProfile = retryBootstrapHandoffProfile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ describe("device bootstrap tokens", () => {
|
||||
expect(loadDeviceBootstrapTokenRecords(baseDir)[issued.token]).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects bootstrap verification when scopes exceed the issued profile", async () => {
|
||||
it("rejects admin verification for the least-privilege default profile", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
@@ -388,6 +388,32 @@ describe("device bootstrap tokens", () => {
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
});
|
||||
|
||||
it("retains admin only for an explicitly full-mobile handoff profile", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
profile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.admin", "operator.pairing", "operator.read"],
|
||||
purpose: "mobile-full",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
|
||||
{
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.admin", "operator.read", "operator.write"],
|
||||
purpose: "mobile-full",
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("logs when issued bootstrap profiles strip overbroad scopes", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const logPath = path.join(baseDir, "bootstrap.log");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
deviceBootstrapProfilesEqual,
|
||||
normalizeDeviceBootstrapHandoffProfile,
|
||||
normalizeDeviceBootstrapProfile,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
@@ -70,26 +71,15 @@ function resolvePersistedPendingProfile(
|
||||
function resolveRequestedBootstrapProfile(params: {
|
||||
role: string;
|
||||
scopes: readonly string[];
|
||||
purpose?: DeviceBootstrapProfile["purpose"];
|
||||
}): DeviceBootstrapProfile {
|
||||
return normalizeDeviceBootstrapProfile({
|
||||
roles: [params.role],
|
||||
scopes: resolveBootstrapProfileScopesForRole(params.role, params.scopes),
|
||||
scopes: resolveBootstrapProfileScopesForRole(params.role, params.scopes, params.purpose),
|
||||
purpose: params.purpose,
|
||||
});
|
||||
}
|
||||
|
||||
function sameBootstrapProfile(
|
||||
left: DeviceBootstrapProfile,
|
||||
right: DeviceBootstrapProfile,
|
||||
): boolean {
|
||||
if (left.roles.length !== right.roles.length || left.scopes.length !== right.scopes.length) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
left.roles.every((role, index) => role === right.roles[index]) &&
|
||||
left.scopes.every((scope, index) => scope === right.scopes[index])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveIssuedBootstrapProfile(params: {
|
||||
profile?: DeviceBootstrapProfileInput;
|
||||
roles?: readonly string[];
|
||||
@@ -100,6 +90,8 @@ function resolveIssuedBootstrapProfile(params: {
|
||||
// Issued tokens can request many roles/scopes, but bootstrap handoff persists only the allowlist.
|
||||
return normalizeDeviceBootstrapHandoffProfile(input);
|
||||
}
|
||||
// Generic bootstrap callers stay least-privilege. Official mobile setup
|
||||
// passes the full profile explicitly after validating the advertised URL.
|
||||
return PAIRING_SETUP_BOOTSTRAP_PROFILE;
|
||||
}
|
||||
|
||||
@@ -155,6 +147,7 @@ function bootstrapProfileSatisfiesProfile(params: {
|
||||
const requiredScopes = resolveBootstrapProfileScopesForRole(
|
||||
requiredRole,
|
||||
params.requiredProfile.scopes,
|
||||
params.requiredProfile.purpose,
|
||||
);
|
||||
if (
|
||||
requiredScopes.length > 0 &&
|
||||
@@ -349,8 +342,9 @@ export async function redeemDeviceBootstrapTokenProfile(params: {
|
||||
roles: [...resolvePersistedRedeemedProfile(record).roles, params.role],
|
||||
scopes: [
|
||||
...resolvePersistedRedeemedProfile(record).scopes,
|
||||
...resolveBootstrapProfileScopesForRole(params.role, params.scopes),
|
||||
...resolveBootstrapProfileScopesForRole(params.role, params.scopes, issuedProfile.purpose),
|
||||
],
|
||||
purpose: issuedProfile.purpose,
|
||||
});
|
||||
const nextPendingProfile =
|
||||
pendingProfile &&
|
||||
@@ -427,6 +421,7 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
const requestedProfile = resolveRequestedBootstrapProfile({
|
||||
role,
|
||||
scopes: params.scopes,
|
||||
purpose: allowedProfile.purpose,
|
||||
});
|
||||
|
||||
const boundDeviceId = record.deviceId?.trim();
|
||||
@@ -439,7 +434,7 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const pendingProfile = resolvePersistedPendingProfile(record);
|
||||
if (pendingProfile && !sameBootstrapProfile(pendingProfile, requestedProfile)) {
|
||||
if (pendingProfile && !deviceBootstrapProfilesEqual(pendingProfile, requestedProfile)) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
state[tokenKey] = {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Covers device pairing, token, and role lifecycle behavior.
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import {
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js";
|
||||
@@ -170,7 +173,7 @@ async function mutatePairedDevice(
|
||||
function mutatePendingRequest(
|
||||
baseDir: string,
|
||||
requestId: string,
|
||||
mutate: (pending: { ts: number; refreshedAtMs?: number }) => void,
|
||||
mutate: (pending: { ts: number; refreshedAtMs?: number; scopes?: string[] }) => void,
|
||||
) {
|
||||
const state = loadDevicePairingStoreState(baseDir);
|
||||
const pending = requireValue(state.pendingById[requestId], "expected pending pairing request");
|
||||
@@ -1367,6 +1370,35 @@ describe("device pairing tokens", () => {
|
||||
expect(paired?.tokens?.operator).toBeUndefined();
|
||||
});
|
||||
|
||||
test("bootstrap pairing treats missing persisted scopes as an empty grant", async () => {
|
||||
const baseDir = await makeDevicePairingDir();
|
||||
const request = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "bootstrap-device-missing-scopes",
|
||||
publicKey: "bootstrap-public-key-missing-scopes",
|
||||
role: "operator",
|
||||
roles: ["operator"],
|
||||
scopes: [],
|
||||
silent: true,
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
mutatePendingRequest(baseDir, request.request.requestId, (pending) => {
|
||||
delete pending.scopes;
|
||||
});
|
||||
|
||||
const approved = await approveBootstrapDevicePairing(
|
||||
request.request.requestId,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
baseDir,
|
||||
);
|
||||
expectRecordFields(approved, "approved result", { status: "approved" });
|
||||
|
||||
const paired = await getPairedDevice("bootstrap-device-missing-scopes", baseDir);
|
||||
expect(paired?.approvedScopes).toStrictEqual([]);
|
||||
expect(paired?.tokens?.operator?.scopes).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test("bootstrap approval access metadata initializes paired device last-seen fields", async () => {
|
||||
const baseDir = await makeDevicePairingDir();
|
||||
const request = await requestDevicePairing(
|
||||
@@ -1405,7 +1437,7 @@ describe("device pairing tokens", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("baseline bootstrap pairing issues bounded operator token when requested by QR handoff", async () => {
|
||||
test("baseline bootstrap pairing issues full operator token when requested by QR handoff", async () => {
|
||||
const baseDir = await makeDevicePairingDir();
|
||||
const request = await requestDevicePairing(
|
||||
{
|
||||
@@ -1413,7 +1445,13 @@ describe("device pairing tokens", () => {
|
||||
publicKey: "bootstrap-public-key-operator-default",
|
||||
role: "node",
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
silent: true,
|
||||
},
|
||||
baseDir,
|
||||
@@ -1421,7 +1459,7 @@ describe("device pairing tokens", () => {
|
||||
|
||||
const approved = await approveBootstrapDevicePairing(
|
||||
request.request.requestId,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
baseDir,
|
||||
);
|
||||
expectRecordFields(approved, "approved result", { status: "approved" });
|
||||
@@ -1430,6 +1468,7 @@ describe("device pairing tokens", () => {
|
||||
const operatorToken = requireToken(paired?.tokens?.operator?.token);
|
||||
expect(paired?.tokens?.node?.scopes).toStrictEqual([]);
|
||||
expect(paired?.tokens?.operator?.scopes).toStrictEqual([
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
@@ -1440,7 +1479,13 @@ describe("device pairing tokens", () => {
|
||||
deviceId: "bootstrap-device-operator-default",
|
||||
token: operatorToken,
|
||||
role: "operator",
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
@@ -1452,7 +1497,7 @@ describe("device pairing tokens", () => {
|
||||
scopes: ["operator.admin"],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: "bootstrap-device-operator-default",
|
||||
@@ -1461,7 +1506,7 @@ describe("device pairing tokens", () => {
|
||||
scopes: ["operator.pairing"],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("bootstrap node approval preserves existing operator token scopes", async () => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
|
||||
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||
import {
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
resolveBootstrapProfileScopesForRoles,
|
||||
resolveDeviceProfileRoleScopes,
|
||||
resolveDeviceProfileScopes,
|
||||
type DeviceBootstrapProfile,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import {
|
||||
@@ -925,10 +925,7 @@ export async function approveBootstrapDevicePairing(
|
||||
: optionsOrBaseDir;
|
||||
const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir;
|
||||
const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? [];
|
||||
const approvedScopes = resolveBootstrapProfileScopesForRoles(
|
||||
approvedRoles,
|
||||
bootstrapProfile.scopes,
|
||||
);
|
||||
const approvedScopes = resolveDeviceProfileScopes(bootstrapProfile, approvedRoles);
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
@@ -951,11 +948,14 @@ export async function approveBootstrapDevicePairing(
|
||||
if (missingScope) {
|
||||
return { status: "forbidden", reason: "bootstrap-scope-not-allowed", scope: missingScope };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||
const grantedRoles = requestedRoles;
|
||||
const grantedScopes = resolveBootstrapProfileScopesForRoles(grantedRoles, pending.scopes ?? []);
|
||||
const grantedScopes = resolveDeviceProfileScopes(
|
||||
bootstrapProfile,
|
||||
grantedRoles,
|
||||
pending.scopes ?? [],
|
||||
);
|
||||
const grantedRoleSet = new Set(grantedRoles);
|
||||
const preservedExistingScopes = (mergeRoles(existing?.roles, existing?.role) ?? []).flatMap(
|
||||
(existingRole) =>
|
||||
@@ -973,7 +973,7 @@ export async function approveBootstrapDevicePairing(
|
||||
const existingToken = tokens[roleForToken];
|
||||
const tokenScopes =
|
||||
roleForToken === OPERATOR_ROLE
|
||||
? resolveBootstrapProfileScopesForRole(roleForToken, grantedScopes)
|
||||
? resolveDeviceProfileRoleScopes(bootstrapProfile, roleForToken, grantedScopes)
|
||||
: [];
|
||||
tokens[roleForToken] = buildDeviceAuthToken({
|
||||
role: roleForToken,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Tests setup code generation and environment-derived defaults.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SecretInput } from "../config/types.secrets.js";
|
||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
|
||||
vi.mock("../infra/device-bootstrap.js", () => ({
|
||||
@@ -26,6 +27,11 @@ describe("pairing setup code", () => {
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
const limitedPlaintextAccess = {
|
||||
bootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
access: "limited" as const,
|
||||
accessDowngraded: true,
|
||||
};
|
||||
const gatewayPasswordSecretRef: SecretInput = {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
@@ -113,7 +119,9 @@ describe("pairing setup code", () => {
|
||||
url?: string;
|
||||
urls?: string[];
|
||||
urlSource?: string;
|
||||
bootstrapProfile?: { roles: string[]; scopes: string[] };
|
||||
bootstrapProfile?: { roles: string[]; scopes: string[]; purpose?: string };
|
||||
access?: "full" | "limited" | "node";
|
||||
accessDowngraded?: boolean;
|
||||
},
|
||||
) {
|
||||
expect(resolved.ok).toBe(true);
|
||||
@@ -126,7 +134,14 @@ describe("pairing setup code", () => {
|
||||
baseDir: undefined,
|
||||
profile: params.bootstrapProfile ?? {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
purpose: "mobile-full",
|
||||
},
|
||||
});
|
||||
if (params.url) {
|
||||
@@ -138,6 +153,8 @@ describe("pairing setup code", () => {
|
||||
if (params.urlSource) {
|
||||
expect(resolved.urlSource).toBe(params.urlSource);
|
||||
}
|
||||
expect(resolved.access).toBe(params.access ?? "full");
|
||||
expect(resolved.accessDowngraded).toBe(params.accessDowngraded ?? false);
|
||||
}
|
||||
|
||||
function expectResolvedSetupError(resolved: ResolvedSetup, snippet: string) {
|
||||
@@ -156,7 +173,9 @@ describe("pairing setup code", () => {
|
||||
url: string;
|
||||
urls?: string[];
|
||||
urlSource: string;
|
||||
bootstrapProfile?: { roles: string[]; scopes: string[] };
|
||||
bootstrapProfile?: { roles: string[]; scopes: string[]; purpose?: string };
|
||||
access?: "full" | "limited" | "node";
|
||||
accessDowngraded?: boolean;
|
||||
};
|
||||
runCommandWithTimeout?: ReturnType<typeof vi.fn>;
|
||||
expectedRunCommandCalls?: number;
|
||||
@@ -276,6 +295,7 @@ describe("pairing setup code", () => {
|
||||
url: "wss://gateway.example.test:18789",
|
||||
urlSource: "plugins.entries.device-pair.config.publicUrl",
|
||||
bootstrapProfile: { roles: ["node"], scopes: [] },
|
||||
access: "node",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -523,6 +543,7 @@ describe("pairing setup code", () => {
|
||||
authLabel: "token",
|
||||
url: "ws://10.0.2.2:18789",
|
||||
urlSource: "gateway.bind=custom",
|
||||
...limitedPlaintextAccess,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -538,6 +559,7 @@ describe("pairing setup code", () => {
|
||||
authLabel: "token",
|
||||
url: "ws://gateway.local:18789",
|
||||
urlSource: "gateway.bind=custom",
|
||||
...limitedPlaintextAccess,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -553,6 +575,7 @@ describe("pairing setup code", () => {
|
||||
authLabel: "token",
|
||||
url: "ws://192.168.1.20:18789",
|
||||
urlSource: "gateway.bind=custom",
|
||||
...limitedPlaintextAccess,
|
||||
},
|
||||
},
|
||||
] as const)("$name", async ({ config, options, expected }) => {
|
||||
@@ -613,6 +636,7 @@ describe("pairing setup code", () => {
|
||||
authLabel: "password",
|
||||
url: "ws://192.168.1.20:18789",
|
||||
urlSource: "gateway.bind=lan",
|
||||
...limitedPlaintextAccess,
|
||||
},
|
||||
runCommandWithTimeout,
|
||||
expectedRunCommandCalls: 3,
|
||||
@@ -658,6 +682,7 @@ describe("pairing setup code", () => {
|
||||
authLabel: "password",
|
||||
url: "ws://10.211.55.3:18789",
|
||||
urlSource: "gateway.bind=lan",
|
||||
...limitedPlaintextAccess,
|
||||
},
|
||||
runCommandWithTimeout,
|
||||
expectedRunCommandCalls: 3,
|
||||
@@ -700,6 +725,7 @@ describe("pairing setup code", () => {
|
||||
url: "ws://192.168.139.3:18789",
|
||||
urls: ["ws://192.168.139.3:18789", "wss://clawmac.tail.ts.net:8443"],
|
||||
urlSource: "gateway.bind=lan",
|
||||
...limitedPlaintextAccess,
|
||||
},
|
||||
runCommandWithTimeout,
|
||||
expectedRunCommandCalls: 2,
|
||||
@@ -724,6 +750,7 @@ describe("pairing setup code", () => {
|
||||
authLabel: "token",
|
||||
url: "ws://192.168.139.3:18789",
|
||||
urlSource: "gateway.bind=custom",
|
||||
...limitedPlaintextAccess,
|
||||
},
|
||||
runCommandWithTimeout,
|
||||
expectedRunCommandCalls: 0,
|
||||
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
safeNetworkInterfaces,
|
||||
} from "../infra/network-interfaces.js";
|
||||
import {
|
||||
deviceBootstrapProfilesEqual,
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
type DeviceBootstrapProfileInput,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
@@ -40,6 +43,8 @@ type PairingSetupPayload = {
|
||||
bootstrapToken: string;
|
||||
};
|
||||
|
||||
export type PairingSetupAccess = "full" | "limited" | "node";
|
||||
|
||||
const PAIRING_SETUP_MAX_URLS = 8;
|
||||
|
||||
type PairingSetupCommandResult = {
|
||||
@@ -70,6 +75,8 @@ type PairingSetupResolution =
|
||||
payload: PairingSetupPayload;
|
||||
authLabel: "token" | "password";
|
||||
urlSource: string;
|
||||
access: PairingSetupAccess;
|
||||
accessDowngraded: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
@@ -145,6 +152,29 @@ function isMobilePairingCleartextAllowedHost(host: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isFullAccessMobilePairingUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "wss:") {
|
||||
return true;
|
||||
}
|
||||
const host = normalizeMobilePairingHost(parsed.hostname);
|
||||
return parsed.protocol === "ws:" && (host === "localhost" || isLoopbackIpAddress(host));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePairingSetupAccess(profile: DeviceBootstrapProfileInput): PairingSetupAccess {
|
||||
if (deviceBootstrapProfilesEqual(profile, FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE)) {
|
||||
return "full";
|
||||
}
|
||||
if (deviceBootstrapProfilesEqual(profile, NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)) {
|
||||
return "node";
|
||||
}
|
||||
return "limited";
|
||||
}
|
||||
|
||||
function validateMobilePairingUrl(url: string, source?: string): string | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -430,6 +460,19 @@ export async function resolvePairingSetupFromConfig(
|
||||
}
|
||||
}
|
||||
const uniqueUrls = [...new Set(urls)].slice(0, PAIRING_SETUP_MAX_URLS);
|
||||
const requestedBootstrapProfile =
|
||||
options.bootstrapProfile ?? FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE;
|
||||
const accessDowngraded =
|
||||
deviceBootstrapProfilesEqual(
|
||||
requestedBootstrapProfile,
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
) && uniqueUrls.some((url) => !isFullAccessMobilePairingUrl(url));
|
||||
// Every advertised URL shares this bearer token. Keep plaintext LAN routes
|
||||
// useful for node/chat access, but reserve admin handoff for an all-TLS
|
||||
// route set (or same-host loopback, where no LAN observer exists).
|
||||
const issuedBootstrapProfile = accessDowngraded
|
||||
? PAIRING_SETUP_BOOTSTRAP_PROFILE
|
||||
: requestedBootstrapProfile;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -439,11 +482,13 @@ export async function resolvePairingSetupFromConfig(
|
||||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
baseDir: options.pairingBaseDir,
|
||||
profile: options.bootstrapProfile ?? PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
profile: issuedBootstrapProfile,
|
||||
})
|
||||
).token,
|
||||
},
|
||||
authLabel: authLabel.label,
|
||||
urlSource: urlResult.source ?? "unknown",
|
||||
access: resolvePairingSetupAccess(issuedBootstrapProfile),
|
||||
accessDowngraded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||
FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
isMobilePairingSetupBootstrapProfile,
|
||||
isNodePairingSetupBootstrapProfile,
|
||||
isPairingSetupBootstrapProfile,
|
||||
normalizeDeviceBootstrapHandoffProfile,
|
||||
@@ -74,6 +76,20 @@ describe("device bootstrap profile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("allows admin only for the closed full-mobile purpose", () => {
|
||||
expect(
|
||||
normalizeDeviceBootstrapHandoffProfile({
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.admin", "operator.pairing", "operator.read"],
|
||||
purpose: "mobile-full",
|
||||
}),
|
||||
).toEqual({
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.admin", "operator.read", "operator.write"],
|
||||
purpose: "mobile-full",
|
||||
});
|
||||
});
|
||||
|
||||
test("drops unknown bootstrap purpose codes", () => {
|
||||
expect(
|
||||
normalizeDeviceBootstrapProfile(
|
||||
@@ -85,35 +101,54 @@ describe("device bootstrap profile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("default setup profile carries node plus bounded operator handoff", () => {
|
||||
test("full setup profile carries node plus full native operator access", () => {
|
||||
expect(FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({
|
||||
roles: ["node", "operator"],
|
||||
scopes: [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
purpose: "mobile-full",
|
||||
});
|
||||
});
|
||||
|
||||
test("existing setup profile preserves the bounded operator handoff", () => {
|
||||
expect(PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
});
|
||||
expect(isPairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
|
||||
expect(isPairingSetupBootstrapProfile(FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(false);
|
||||
});
|
||||
|
||||
test("node setup profile carries no operator access", () => {
|
||||
expect(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ roles: ["node"], scopes: [] });
|
||||
expect(isNodePairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
|
||||
expect(isPairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(false);
|
||||
expect(isMobilePairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(false);
|
||||
});
|
||||
|
||||
test("recognizes only the current setup profile", () => {
|
||||
expect(isPairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
|
||||
test("recognizes only the supported mobile setup profiles", () => {
|
||||
expect(isMobilePairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true);
|
||||
expect(isMobilePairingSetupBootstrapProfile(FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isPairingSetupBootstrapProfile({
|
||||
isMobilePairingSetupBootstrapProfile({
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.write"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPairingSetupBootstrapProfile({
|
||||
isMobilePairingSetupBootstrapProfile({
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.pairing", "operator.read", "operator.write"],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPairingSetupBootstrapProfile({
|
||||
isMobilePairingSetupBootstrapProfile({
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.admin", "operator.approvals", "operator.read", "operator.write"],
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "./device-auth.js";
|
||||
|
||||
/** Closed purpose codes carried by specialized bootstrap tokens. */
|
||||
export type DeviceBootstrapPurpose = "control-ui";
|
||||
export type DeviceBootstrapPurpose = "control-ui" | "mobile-full";
|
||||
|
||||
/** Normalized roles/scopes carried by a bootstrap token during device handoff. */
|
||||
export type DeviceBootstrapProfile = {
|
||||
@@ -28,7 +28,15 @@ export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [
|
||||
|
||||
const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set<string>(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES);
|
||||
|
||||
/** Default setup-code/QR bootstrap profile for native onboarding handoff. */
|
||||
/** Full native-mobile operator scopes allowed only by the closed mobile setup profile. */
|
||||
const MOBILE_FULL_ACCESS_OPERATOR_SCOPES = [
|
||||
"operator.admin",
|
||||
...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||
] as const;
|
||||
|
||||
const MOBILE_FULL_ACCESS_OPERATOR_SCOPE_SET = new Set<string>(MOBILE_FULL_ACCESS_OPERATOR_SCOPES);
|
||||
|
||||
/** Existing least-privilege setup-code/QR profile. */
|
||||
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
// QR/setup-code bootstrap must hand off both tokens for native onboarding:
|
||||
// iOS/Android suppress the operator loop while bootstrap auth is active and
|
||||
@@ -37,18 +45,28 @@ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
|
||||
};
|
||||
|
||||
/** Full native-mobile setup profile for explicitly authorized setup surfaces. */
|
||||
export const FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
roles: ["node", "operator"],
|
||||
scopes: [...MOBILE_FULL_ACCESS_OPERATOR_SCOPES],
|
||||
purpose: "mobile-full",
|
||||
};
|
||||
|
||||
/** Node-only setup profile for companions that never act as operators. */
|
||||
export const NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
};
|
||||
|
||||
function matchesBootstrapProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
expected: DeviceBootstrapProfile,
|
||||
/** Compare normalized bootstrap profiles, including their closed purpose. */
|
||||
export function deviceBootstrapProfilesEqual(
|
||||
left: DeviceBootstrapProfileInput | undefined,
|
||||
right: DeviceBootstrapProfileInput | undefined,
|
||||
): boolean {
|
||||
const profile = normalizeDeviceBootstrapProfile(input);
|
||||
const profile = normalizeDeviceBootstrapProfile(left);
|
||||
const expected = normalizeDeviceBootstrapProfile(right);
|
||||
return (
|
||||
profile.purpose === expected.purpose &&
|
||||
profile.roles.length === expected.roles.length &&
|
||||
profile.scopes.length === expected.scopes.length &&
|
||||
profile.roles.every((role, index) => role === expected.roles[index]) &&
|
||||
@@ -56,7 +74,24 @@ function matchesBootstrapProfile(
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether an input exactly matches the current setup-code bootstrap profile. */
|
||||
function matchesBootstrapProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
expected: DeviceBootstrapProfile,
|
||||
): boolean {
|
||||
return deviceBootstrapProfilesEqual(input, expected);
|
||||
}
|
||||
|
||||
/** Return whether an input matches either supported native-mobile setup profile. */
|
||||
export function isMobilePairingSetupBootstrapProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
isPairingSetupBootstrapProfile(input) ||
|
||||
matchesBootstrapProfile(input, FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE)
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether an input exactly matches the existing limited setup profile. */
|
||||
export function isPairingSetupBootstrapProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
): boolean {
|
||||
@@ -74,11 +109,16 @@ export function isNodePairingSetupBootstrapProfile(
|
||||
export function resolveBootstrapProfileScopesForRole(
|
||||
role: string,
|
||||
scopes: readonly string[],
|
||||
purpose?: DeviceBootstrapPurpose,
|
||||
): string[] {
|
||||
const normalizedRole = normalizeDeviceAuthRole(role);
|
||||
const normalizedScopes = normalizeDeviceAuthScopes(Array.from(scopes));
|
||||
if (normalizedRole === "operator") {
|
||||
return normalizedScopes.filter((scope) => BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET.has(scope));
|
||||
const allowedScopes =
|
||||
purpose === "mobile-full"
|
||||
? MOBILE_FULL_ACCESS_OPERATOR_SCOPE_SET
|
||||
: BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET;
|
||||
return normalizedScopes.filter((scope) => allowedScopes.has(scope));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -87,12 +127,31 @@ export function resolveBootstrapProfileScopesForRole(
|
||||
export function resolveBootstrapProfileScopesForRoles(
|
||||
roles: readonly string[],
|
||||
scopes: readonly string[],
|
||||
purpose?: DeviceBootstrapPurpose,
|
||||
): string[] {
|
||||
return normalizeDeviceAuthScopes(
|
||||
roles.flatMap((role) => resolveBootstrapProfileScopesForRole(role, scopes)),
|
||||
roles.flatMap((role) => resolveBootstrapProfileScopesForRole(role, scopes, purpose)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve one role's scopes directly from a normalized bootstrap profile. */
|
||||
export function resolveDeviceProfileRoleScopes(
|
||||
profile: DeviceBootstrapProfile,
|
||||
role: string,
|
||||
scopes: readonly string[] = profile.scopes,
|
||||
): string[] {
|
||||
return resolveBootstrapProfileScopesForRole(role, scopes, profile.purpose);
|
||||
}
|
||||
|
||||
/** Resolve role-set scopes directly from a normalized bootstrap profile. */
|
||||
export function resolveDeviceProfileScopes(
|
||||
profile: DeviceBootstrapProfile,
|
||||
roles: readonly string[],
|
||||
scopes: readonly string[] = profile.scopes,
|
||||
): string[] {
|
||||
return resolveBootstrapProfileScopesForRoles(roles, scopes, profile.purpose);
|
||||
}
|
||||
|
||||
/** Normalize a requested bootstrap profile and strip scopes outside the handoff allowlist. */
|
||||
export function normalizeDeviceBootstrapHandoffProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
@@ -101,7 +160,7 @@ export function normalizeDeviceBootstrapHandoffProfile(
|
||||
// Bootstrap handoff profiles can only carry the documented handoff allowlist.
|
||||
return {
|
||||
roles: profile.roles,
|
||||
scopes: resolveBootstrapProfileScopesForRoles(profile.roles, profile.scopes),
|
||||
scopes: resolveBootstrapProfileScopesForRoles(profile.roles, profile.scopes, profile.purpose),
|
||||
...(profile.purpose ? { purpose: profile.purpose } : {}),
|
||||
};
|
||||
}
|
||||
@@ -124,7 +183,8 @@ function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[]
|
||||
export function normalizeDeviceBootstrapProfile(
|
||||
input: DeviceBootstrapProfileInput | undefined,
|
||||
): DeviceBootstrapProfile {
|
||||
const purpose = input?.purpose === "control-ui" ? input.purpose : undefined;
|
||||
const purpose =
|
||||
input?.purpose === "control-ui" || input?.purpose === "mobile-full" ? input.purpose : undefined;
|
||||
return {
|
||||
roles: normalizeBootstrapRoles(input?.roles),
|
||||
scopes: normalizeDeviceAuthScopes(input?.scopes ? [...input.scopes] : []),
|
||||
|
||||
@@ -61,7 +61,6 @@ type ShellRouteState = {
|
||||
routeId?: RouteId;
|
||||
location?: RouteLocation;
|
||||
};
|
||||
|
||||
type AppSidebarElement = HTMLElement & {
|
||||
dismissTransientMenus: () => boolean;
|
||||
};
|
||||
@@ -1216,8 +1215,10 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
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(),
|
||||
onCopy: (setupCode) => void copyToClipboard(setupCode),
|
||||
onManageDevices: () => {
|
||||
@@ -1229,7 +1230,6 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-app")) {
|
||||
customElements.define("openclaw-app", OpenClawApp);
|
||||
}
|
||||
|
||||
+24
-24
@@ -6,10 +6,13 @@ import type { GatewayEventFrame, GatewayHelloOk } from "../api/gateway.ts";
|
||||
import type { UpdateAvailable } from "../api/types.ts";
|
||||
import {
|
||||
closeDevicePairSetup as closeDevicePairSetupState,
|
||||
createDevicePairSetupState,
|
||||
openDevicePairSetup as openDevicePairSetupState,
|
||||
readDevicePairSetupSnapshot,
|
||||
refreshDevicePairSetup as refreshDevicePairSetupState,
|
||||
setDevicePairSetupAccess as setPairAccess,
|
||||
type DevicePairSetup,
|
||||
type DevicePairSetupState,
|
||||
type DevicePairSetupAccess,
|
||||
} from "../lib/device-pair-setup.ts";
|
||||
import {
|
||||
clearResolvedExecApprovalPrompt,
|
||||
@@ -43,6 +46,7 @@ export type ApplicationOverlaySnapshot = {
|
||||
devicePairSetupLoading: boolean;
|
||||
devicePairSetupError: string | null;
|
||||
devicePairSetup: DevicePairSetup | null;
|
||||
devicePairSetupAccess: DevicePairSetupAccess;
|
||||
devicePairPendingCount: number;
|
||||
};
|
||||
|
||||
@@ -53,6 +57,7 @@ export type ApplicationOverlays = {
|
||||
decideApproval: (decision: ExecApprovalDecision) => Promise<void>;
|
||||
openDevicePairSetup: () => Promise<void>;
|
||||
refreshDevicePairSetup: () => Promise<void>;
|
||||
setDevicePairSetupAccess: (access: DevicePairSetupAccess) => Promise<void>;
|
||||
closeDevicePairSetup: () => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
@@ -212,6 +217,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
|
||||
devicePairSetupLoading: false,
|
||||
devicePairSetupError: null,
|
||||
devicePairSetup: null,
|
||||
devicePairSetupAccess: "full",
|
||||
devicePairPendingCount: 0,
|
||||
};
|
||||
const listeners = new Set<(next: ApplicationOverlaySnapshot) => void>();
|
||||
@@ -232,15 +238,10 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
|
||||
epoch: number;
|
||||
id: string;
|
||||
} | null = null;
|
||||
const devicePairSetupState: DevicePairSetupState & { pendingCount: number } = {
|
||||
const devicePairSetupState = createDevicePairSetupState({
|
||||
client: gateway.snapshot.client,
|
||||
connected: gateway.snapshot.connected,
|
||||
devicePairSetupOpen: false,
|
||||
devicePairSetupLoading: false,
|
||||
devicePairSetupError: null,
|
||||
devicePairSetup: null,
|
||||
pendingCount: 0,
|
||||
};
|
||||
});
|
||||
const promptState: ExecApprovalPromptState = {
|
||||
client: activeClient,
|
||||
execApprovalQueue: [],
|
||||
@@ -260,18 +261,20 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
|
||||
approvalQueue: promptState.execApprovalQueue,
|
||||
approvalBusy: promptState.execApprovalBusy,
|
||||
approvalError: promptState.execApprovalError,
|
||||
devicePairSetupOpen: devicePairSetupState.devicePairSetupOpen,
|
||||
devicePairSetupLoading: devicePairSetupState.devicePairSetupLoading,
|
||||
devicePairSetupError: devicePairSetupState.devicePairSetupError,
|
||||
devicePairSetup: devicePairSetupState.devicePairSetup,
|
||||
devicePairPendingCount: devicePairSetupState.pendingCount,
|
||||
...readDevicePairSetupSnapshot(devicePairSetupState),
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
};
|
||||
promptState.execApprovalExpired = publish;
|
||||
|
||||
const publishDevicePairSetupOperation = async (operation: Promise<void>) => {
|
||||
publish();
|
||||
await operation;
|
||||
if (!disposed) {
|
||||
publish();
|
||||
}
|
||||
};
|
||||
const isCurrentClient = (client: NonNullable<typeof activeClient>) =>
|
||||
!disposed &&
|
||||
activeClient === client &&
|
||||
@@ -672,22 +675,19 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
|
||||
const setupOperation = openDevicePairSetupState(devicePairSetupState);
|
||||
// Pairing-list latency must not keep a ready setup code behind the loading state.
|
||||
void refreshDevicePairPendingCount();
|
||||
publish();
|
||||
await setupOperation;
|
||||
if (!disposed) {
|
||||
publish();
|
||||
}
|
||||
await publishDevicePairSetupOperation(setupOperation);
|
||||
},
|
||||
async refreshDevicePairSetup() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const operation = refreshDevicePairSetupState(devicePairSetupState);
|
||||
publish();
|
||||
await operation;
|
||||
if (!disposed) {
|
||||
publish();
|
||||
await publishDevicePairSetupOperation(refreshDevicePairSetupState(devicePairSetupState));
|
||||
},
|
||||
async setDevicePairSetupAccess(access) {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
await publishDevicePairSetupOperation(setPairAccess(devicePairSetupState, access));
|
||||
},
|
||||
closeDevicePairSetup() {
|
||||
devicePairPendingCountGeneration += 1;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Control UI tests cover mobile pairing setup through the mocked Gateway.
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import qrcode from "qrcode";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -14,6 +16,7 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/mobile-pairing");
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
@@ -34,7 +37,7 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("opens pairing from the app shell and Quick Settings", async () => {
|
||||
it("defaults to full before issuance, supports limited fallback, and resets when reopened", async () => {
|
||||
const setupCode = Buffer.from(
|
||||
JSON.stringify({
|
||||
url: "wss://gateway.example.test",
|
||||
@@ -43,8 +46,10 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
const qrDataUrl = await qrcode.toDataURL(setupCode, { margin: 2, width: 360 });
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } },
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
@@ -83,26 +88,51 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
|
||||
const dialog = page.getByRole("dialog", { name: "OpenClaw mobile" });
|
||||
const qr = page.getByAltText("OpenClaw mobile pairing QR code");
|
||||
await dialog.waitFor();
|
||||
await qr.waitFor();
|
||||
expect(await dialog.isVisible()).toBe(true);
|
||||
expect(await qr.getAttribute("src")).toMatch(/^data:image\/png;base64,/u);
|
||||
expect(await page.getByText("wss://gateway.example.test", { exact: true }).isVisible()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await page.getByText("Official OpenClaw mobile apps connect automatically").isVisible(),
|
||||
).toBe(true);
|
||||
expect(await qr.count()).toBe(0);
|
||||
expect(await gateway.getRequests("device.pair.setupCode")).toEqual([]);
|
||||
expect(await page.getByRole("button", { name: "Create setup code" }).isVisible()).toBe(true);
|
||||
await gateway.resolveDeferred("device.pair.list", {
|
||||
paired: [],
|
||||
pending: [{ deviceId: "mobile-1", requestId: "request-1" }],
|
||||
});
|
||||
expect(await page.getByText("Device requests waiting for review: 1").isVisible()).toBe(true);
|
||||
expect(
|
||||
await page.getByText("Official OpenClaw mobile apps connect automatically").isVisible(),
|
||||
).toBe(false);
|
||||
|
||||
// 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);
|
||||
const fullAccess = accessRadios.nth(0);
|
||||
const limitedAccess = accessRadios.nth(1);
|
||||
expect(await fullAccess.isChecked()).toBe(true);
|
||||
await page.screenshot({ path: path.join(artifactDir, "01-full-access-default.png") });
|
||||
|
||||
await limitedAccess.check();
|
||||
expect(await limitedAccess.isChecked()).toBe(true);
|
||||
await fullAccess.check();
|
||||
expect(await gateway.getRequests("device.pair.setupCode")).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: "Create setup code" }).click();
|
||||
const firstRequest = await gateway.waitForRequest("device.pair.setupCode");
|
||||
expect(firstRequest.params).toEqual({});
|
||||
await qr.waitFor();
|
||||
expect(await qr.getAttribute("src")).toMatch(/^data:image\/png;base64,/u);
|
||||
expect(await page.getByText("wss://gateway.example.test", { exact: true }).isVisible()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await page.getByText("Device requests waiting for review: 1").isVisible()).toBe(true);
|
||||
expect(await fullAccess.isDisabled()).toBe(true);
|
||||
expect(await limitedAccess.isDisabled()).toBe(true);
|
||||
await page.screenshot({ path: path.join(artifactDir, "02-full-access-code.png") });
|
||||
|
||||
const accessSequenceBeforeClose = (await gateway.getRequests("device.pair.setupCode")).map(
|
||||
(request) =>
|
||||
request.params &&
|
||||
typeof request.params === "object" &&
|
||||
"bootstrapProfile" in request.params &&
|
||||
request.params.bootstrapProfile === "limited"
|
||||
? "limited"
|
||||
: "full",
|
||||
);
|
||||
expect(accessSequenceBeforeClose).toEqual(["full"]);
|
||||
await expect.poll(async () => (await gateway.getRequests("device.pair.list")).length).toBe(1);
|
||||
|
||||
await gateway.emitGatewayEvent("device.pair.requested", { requestId: "request-2" });
|
||||
@@ -121,14 +151,51 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => {
|
||||
.length;
|
||||
await quickSettingsPairingButton.click();
|
||||
await dialog.waitFor();
|
||||
expect((await gateway.getRequests("device.pair.setupCode")).length).toBe(
|
||||
setupRequestsBeforeQuickSettings,
|
||||
);
|
||||
expect(await page.locator('input[name="device-pair-access"]').nth(0).isChecked()).toBe(true);
|
||||
const reopenedLimitedAccess = page.locator('input[name="device-pair-access"]').nth(1);
|
||||
await reopenedLimitedAccess.check();
|
||||
await page.getByRole("button", { name: "Create setup code" }).click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("device.pair.setupCode")).length)
|
||||
.toBe(setupRequestsBeforeQuickSettings + 1);
|
||||
await qr.waitFor();
|
||||
const reopenedAccessSequence = (await gateway.getRequests("device.pair.setupCode"))
|
||||
.slice(setupRequestsBeforeQuickSettings)
|
||||
.map((request) =>
|
||||
request.params &&
|
||||
typeof request.params === "object" &&
|
||||
"bootstrapProfile" in request.params &&
|
||||
request.params.bootstrapProfile === "limited"
|
||||
? "limited"
|
||||
: "full",
|
||||
);
|
||||
expect(reopenedAccessSequence).toEqual(["limited"]);
|
||||
const accessSequence = [...accessSequenceBeforeClose, ...reopenedAccessSequence];
|
||||
expect(accessSequence).toEqual(["full", "limited"]);
|
||||
await page.screenshot({ path: path.join(artifactDir, "03-limited-access-code.png") });
|
||||
writeFileSync(
|
||||
path.join(artifactDir, "behavior-summary.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
accessSequence,
|
||||
reopenedDefault: "full",
|
||||
setupRequestsIssued: accessSequence.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "New code" }).click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("device.pair.setupCode")).length)
|
||||
.toBe(setupRequestsBeforeQuickSettings + 2);
|
||||
expect((await gateway.getRequests("device.pair.setupCode")).at(-1)?.params).toEqual({
|
||||
bootstrapProfile: "limited",
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Manage devices" }).click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/nodes");
|
||||
|
||||
@@ -228,6 +228,15 @@ export const en: TranslationMap = {
|
||||
title: "OpenClaw mobile",
|
||||
subtitle: "Scan this QR code in the mobile app to connect a new phone.",
|
||||
generating: "Creating a secure setup code…",
|
||||
accessTitle: "Mobile access",
|
||||
fullAccess: "Full access (recommended)",
|
||||
fullAccessHint: "Node plus complete Gateway controls, including settings and upgrades.",
|
||||
limitedAccess: "Limited access",
|
||||
limitedAccessHint: "Node, chat, and approvals without administrative controls.",
|
||||
generateCode: "Create setup code",
|
||||
transportLimitedTitle: "Limited for network safety",
|
||||
transportLimitedHint:
|
||||
"This Gateway URL uses plaintext ws://. Use wss:// or Tailscale Serve, then create a new code for full access.",
|
||||
failed: "Could not create a setup code.",
|
||||
qrAlt: "OpenClaw mobile pairing QR code",
|
||||
qrUnavailable: "QR unavailable. Copy the setup code instead.",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
closeDevicePairSetup,
|
||||
createDevicePairSetupState,
|
||||
openDevicePairSetup,
|
||||
refreshDevicePairSetup,
|
||||
setDevicePairSetupAccess,
|
||||
type DevicePairSetup,
|
||||
type DevicePairSetupState,
|
||||
} from "./device-pair-setup.ts";
|
||||
|
||||
type DevicePairSetupState = ReturnType<typeof createDevicePairSetupState>;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
@@ -14,27 +18,41 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function setupResult(setupCode: string): DevicePairSetup {
|
||||
function setupResult(
|
||||
setupCode: string,
|
||||
access?: "full" | "limited",
|
||||
accessDowngraded?: boolean,
|
||||
): DevicePairSetup {
|
||||
return {
|
||||
setupCode,
|
||||
gatewayUrl: "wss://gateway.example.com",
|
||||
auth: "token",
|
||||
urlSource: "test",
|
||||
...(access ? { access } : {}),
|
||||
...(accessDowngraded ? { accessDowngraded: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stateWithClient(client: DevicePairSetupState["client"]): DevicePairSetupState {
|
||||
return {
|
||||
client,
|
||||
connected: true,
|
||||
devicePairSetupOpen: true,
|
||||
devicePairSetupLoading: false,
|
||||
devicePairSetupError: null,
|
||||
devicePairSetup: null,
|
||||
};
|
||||
const state = createDevicePairSetupState({ client, connected: true });
|
||||
state.devicePairSetupOpen = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("device pairing setup state", () => {
|
||||
it("opens without minting a setup credential", async () => {
|
||||
const request = vi.fn();
|
||||
const state = createDevicePairSetupState({
|
||||
client: { request } as unknown as DevicePairSetupState["client"],
|
||||
connected: true,
|
||||
});
|
||||
|
||||
await openDevicePairSetup(state);
|
||||
|
||||
expect(state.devicePairSetupOpen).toBe(true);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a setup response from a replaced Gateway client", async () => {
|
||||
const oldResponse = deferred<DevicePairSetup>();
|
||||
const newResponse = deferred<DevicePairSetup>();
|
||||
@@ -102,5 +120,37 @@ describe("device pairing setup state", () => {
|
||||
expect(state.devicePairSetupLoading).toBe(false);
|
||||
expect(state.devicePairSetupError).toBeNull();
|
||||
expect(state.devicePairSetup).toBeNull();
|
||||
expect(state.devicePairSetupAccess).toBe("full");
|
||||
});
|
||||
|
||||
it("selects limited access before issuing a setup code", async () => {
|
||||
const request = vi.fn().mockResolvedValue(setupResult("LIMITED"));
|
||||
const client = {
|
||||
request,
|
||||
} as unknown as DevicePairSetupState["client"];
|
||||
const state = stateWithClient(client);
|
||||
|
||||
await setDevicePairSetupAccess(state, "limited");
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
await refreshDevicePairSetup(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("device.pair.setupCode", {
|
||||
bootstrapProfile: "limited",
|
||||
});
|
||||
expect(state.devicePairSetupAccess).toBe("limited");
|
||||
expect(state.devicePairSetup?.setupCode).toBe("LIMITED");
|
||||
});
|
||||
|
||||
it("reflects a server-side plaintext downgrade", async () => {
|
||||
const request = vi.fn().mockResolvedValue(setupResult("LIMITED", "limited", true));
|
||||
const state = stateWithClient({
|
||||
request,
|
||||
} as unknown as DevicePairSetupState["client"]);
|
||||
|
||||
await refreshDevicePairSetup(state);
|
||||
|
||||
expect(state.devicePairSetupAccess).toBe("limited");
|
||||
expect(state.devicePairSetup?.accessDowngraded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,21 +6,50 @@ type GatewayRequestClient = {
|
||||
};
|
||||
|
||||
export type DevicePairSetup = DevicePairSetupCodeResult;
|
||||
export type DevicePairSetupAccess = "full" | "limited";
|
||||
|
||||
export type DevicePairSetupState = {
|
||||
type DevicePairSetupState = {
|
||||
client: GatewayRequestClient | null;
|
||||
connected: boolean;
|
||||
devicePairSetupOpen: boolean;
|
||||
devicePairSetupLoading: boolean;
|
||||
devicePairSetupError: string | null;
|
||||
devicePairSetup: DevicePairSetup | null;
|
||||
devicePairSetupAccess: DevicePairSetupAccess;
|
||||
};
|
||||
|
||||
type DevicePairSetupOverlayState = DevicePairSetupState & { pendingCount: number };
|
||||
|
||||
export function createDevicePairSetupState(params: {
|
||||
client: DevicePairSetupState["client"];
|
||||
connected: boolean;
|
||||
}): DevicePairSetupOverlayState {
|
||||
return {
|
||||
...params,
|
||||
devicePairSetupOpen: false,
|
||||
devicePairSetupLoading: false,
|
||||
devicePairSetupError: null,
|
||||
devicePairSetup: null,
|
||||
devicePairSetupAccess: "full",
|
||||
pendingCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function readDevicePairSetupSnapshot(state: DevicePairSetupOverlayState) {
|
||||
return {
|
||||
devicePairSetupOpen: state.devicePairSetupOpen,
|
||||
devicePairSetupLoading: state.devicePairSetupLoading,
|
||||
devicePairSetupError: state.devicePairSetupError,
|
||||
devicePairSetup: state.devicePairSetup,
|
||||
devicePairSetupAccess: state.devicePairSetupAccess,
|
||||
devicePairPendingCount: state.pendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
const devicePairSetupRequests = new WeakMap<DevicePairSetupState, object>();
|
||||
|
||||
export async function openDevicePairSetup(state: DevicePairSetupState) {
|
||||
state.devicePairSetupOpen = true;
|
||||
await refreshDevicePairSetup(state);
|
||||
}
|
||||
|
||||
export async function refreshDevicePairSetup(state: DevicePairSetupState) {
|
||||
@@ -33,7 +62,10 @@ export async function refreshDevicePairSetup(state: DevicePairSetupState) {
|
||||
state.devicePairSetupLoading = true;
|
||||
state.devicePairSetupError = null;
|
||||
try {
|
||||
const result = await client.request<DevicePairSetup>("device.pair.setupCode", {});
|
||||
const result = await client.request<DevicePairSetup>(
|
||||
"device.pair.setupCode",
|
||||
state.devicePairSetupAccess === "limited" ? { bootstrapProfile: "limited" } : {},
|
||||
);
|
||||
if (
|
||||
devicePairSetupRequests.get(state) !== requestToken ||
|
||||
state.client !== client ||
|
||||
@@ -42,6 +74,9 @@ export async function refreshDevicePairSetup(state: DevicePairSetupState) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (result.access === "full" || result.access === "limited") {
|
||||
state.devicePairSetupAccess = result.access;
|
||||
}
|
||||
state.devicePairSetup = result;
|
||||
} catch (err) {
|
||||
if (
|
||||
@@ -60,10 +95,28 @@ export async function refreshDevicePairSetup(state: DevicePairSetupState) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDevicePairSetupAccess(
|
||||
state: DevicePairSetupState,
|
||||
access: DevicePairSetupAccess,
|
||||
) {
|
||||
if (
|
||||
state.devicePairSetupAccess === access ||
|
||||
state.devicePairSetupLoading ||
|
||||
state.devicePairSetup !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Choose access before minting a bearer setup credential. Once a code exists,
|
||||
// closing the dialog starts a fresh selection instead of implying revocation.
|
||||
state.devicePairSetupAccess = access;
|
||||
state.devicePairSetupError = null;
|
||||
}
|
||||
|
||||
export function closeDevicePairSetup(state: DevicePairSetupState) {
|
||||
devicePairSetupRequests.delete(state);
|
||||
state.devicePairSetupOpen = false;
|
||||
state.devicePairSetupLoading = false;
|
||||
state.devicePairSetupError = null;
|
||||
state.devicePairSetup = null;
|
||||
state.devicePairSetupAccess = "full";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { html, nothing } from "lit";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/modal-dialog.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { DevicePairSetup } from "../../lib/device-pair-setup.ts";
|
||||
import type { DevicePairSetup, DevicePairSetupAccess } from "../../lib/device-pair-setup.ts";
|
||||
|
||||
const PAIRING_DOCS_URL =
|
||||
"https://docs.openclaw.ai/channels/pairing#pair-from-the-control-ui-recommended";
|
||||
@@ -13,8 +13,10 @@ type DevicePairSetupProps = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setup: DevicePairSetup | null;
|
||||
access: DevicePairSetupAccess;
|
||||
pendingCount: number;
|
||||
onRefresh: () => void;
|
||||
onAccessChange: (access: DevicePairSetupAccess) => void;
|
||||
onClose: () => void;
|
||||
onCopy: (setupCode: string) => void;
|
||||
onManageDevices: () => void;
|
||||
@@ -50,6 +52,40 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
|
||||
</header>
|
||||
|
||||
<div class="device-pair-setup__body">
|
||||
<fieldset class="device-pair-setup__access" ?disabled=${props.loading || setup !== null}>
|
||||
<legend>${t("nodes.pairing.accessTitle")}</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="device-pair-access"
|
||||
.checked=${props.access === "full"}
|
||||
@change=${() => props.onAccessChange("full")}
|
||||
/>
|
||||
<span>
|
||||
<strong>${t("nodes.pairing.fullAccess")}</strong>
|
||||
<small>${t("nodes.pairing.fullAccessHint")}</small>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="device-pair-access"
|
||||
.checked=${props.access === "limited"}
|
||||
@change=${() => props.onAccessChange("limited")}
|
||||
/>
|
||||
<span>
|
||||
<strong>${t("nodes.pairing.limitedAccess")}</strong>
|
||||
<small>${t("nodes.pairing.limitedAccessHint")}</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
${!setup && !props.loading && !props.error
|
||||
? html`
|
||||
<button class="btn primary" type="button" @click=${props.onRefresh}>
|
||||
${icons.smartphone} ${t("nodes.pairing.generateCode")}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
${props.loading && !setup
|
||||
? html`
|
||||
<div class="device-pair-setup__loading" role="status">
|
||||
@@ -102,6 +138,15 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${setup.accessDowngraded
|
||||
? html`
|
||||
<div class="callout warn device-pair-setup__access-warning" role="status">
|
||||
<strong>${t("nodes.pairing.transportLimitedTitle")}</strong>
|
||||
<span>${t("nodes.pairing.transportLimitedHint")}</span>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div class="device-pair-setup__actions">
|
||||
<button
|
||||
class="btn primary"
|
||||
|
||||
@@ -5087,6 +5087,67 @@ td.data-table-key-col {
|
||||
padding: 0 28px 24px;
|
||||
}
|
||||
|
||||
.device-pair-setup__access {
|
||||
display: grid;
|
||||
width: min(440px, 100%);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.device-pair-setup__access legend {
|
||||
margin: 0 auto 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.device-pair-setup__access label {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-pair-setup__access label:has(input:checked) {
|
||||
border-color: color-mix(in srgb, var(--accent) 65%, var(--border));
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.device-pair-setup__access:disabled label {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.device-pair-setup__access input {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.device-pair-setup__access span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.device-pair-setup__access strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.device-pair-setup__access small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.device-pair-setup__loading {
|
||||
display: flex;
|
||||
min-height: 260px;
|
||||
@@ -5128,6 +5189,15 @@ td.data-table-key-col {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.device-pair-setup__access-warning {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
.device-pair-setup__access-warning span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.device-pair-setup__qr-frame {
|
||||
display: grid;
|
||||
width: min(320px, 100%);
|
||||
@@ -5263,6 +5333,10 @@ td.data-table-key-col {
|
||||
width: min(300px, 100%);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.device-pair-setup__access {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.md-preview-expand-btn .when-fullscreen {
|
||||
|
||||
Reference in New Issue
Block a user