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:
Peter Steinberger
2026-07-13 02:30:45 -07:00
committed by GitHub
parent 16b1bd2032
commit 938d41014f
66 changed files with 1456 additions and 224 deletions
@@ -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 lenvoi."</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 lhistorique 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 nexpose pas encore la génération de code de configuration à lapplication. Générez le QR/code sur lhô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 laccès dappairage"</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 lapprobation…"</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">"Gatewayi 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 gatewaye 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
View File
@@ -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
View File
@@ -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.
+10 -1
View File
@@ -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:
+9 -1
View File
@@ -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:
+59 -10
View File
@@ -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([
+71 -8
View File
@@ -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
View File
@@ -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();
});
+14
View File
@@ -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,
);
+129 -13
View File
@@ -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;
}
}
}
+27 -1
View File
@@ -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");
+11 -16
View File
@@ -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] = {
+53 -8
View File
@@ -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 () => {
+9 -9
View File
@@ -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,
+30 -3
View File
@@ -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,
+46 -1
View File
@@ -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,
};
}
+42 -7
View File
@@ -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"],
}),
+71 -11
View File
@@ -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] : []),
+2 -2
View File
@@ -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
View File
@@ -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;
+80 -13
View File
@@ -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");
+9
View File
@@ -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.",
+60 -10
View File
@@ -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);
});
});
+56 -3
View File
@@ -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";
}
+46 -1
View File
@@ -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"
+74
View File
@@ -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 {