diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 85b36a2beb50..e7094f951e45 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -900,13 +900,12 @@ class GatewaySession( ): List? = 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", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt index bf6d46c7ac0b..8ba6b1a489a5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt @@ -480,6 +480,7 @@ internal val nativeStringResourceIds: Map = "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 = "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 = "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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index e33326fedea2..e0fad141a502 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -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, diff --git a/apps/android/app/src/main/res/values-ar/strings.xml b/apps/android/app/src/main/res/values-ar/strings.xml index 8f4532ced5df..bfa5800fe5b7 100644 --- a/apps/android/app/src/main/res/values-ar/strings.xml +++ b/apps/android/app/src/main/res/values-ar/strings.xml @@ -24,6 +24,7 @@ "تعذّر تجهيز مرفق للإرسال." "الميكروفون متوقف" "عرض تنبيهات OpenClaw" + "Full" "تمت الموافقة والحفظ." "عرض سجل المكالمات الأخيرة" "1 قيد الانتظار" @@ -483,6 +484,7 @@ "يتم عرض أحدث جزء من السجل." "استخدام رمز الإعداد" "sticker" + "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." "steer" "محدد" "يمكن لـ Android مسح رمز إعداد موجود أو لصقه، لكن هذه البوابة لا تتيح بعد إنشاء رمز الإعداد للتطبيق. أنشئ رمز QR/الرمز على مضيف البوابة باستخدام openclaw qr، ثم امسحه هنا أو الصق رمز الإعداد أدناه." @@ -695,6 +697,7 @@ "في قائمة الانتظار — سيُرسل عند إعادة الاتصال" "قبل %1$s د" "جارٍ التحقق من صلاحية الاقتران" + "Limited Gateway access" "جارٍ تشغيل الأدوات..." "جارٍ التحقق من الموافقة…" "التقاط الصور والمقاطع من هذا الهاتف" @@ -1251,6 +1254,7 @@ "إعادة الاتصال بـ Gateway" "جهة خارجية" "مراجعة الجاهزية" + "محدود" "شعار OpenClaw" "إلغاء تثبيت النموذج" "أسطح المراسلة المتصلة بهذا Gateway." diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index f6e162f1b0ef..c4a50b6b0311 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -24,6 +24,7 @@ "Ein Anhang konnte nicht für den Versand vorbereitet werden." "Mikrofon aus" "OpenClaw-Warnmeldungen anzeigen" + "Full" "Genehmigung erteilt und gespeichert." "Letzte Anrufe anzeigen" "1 ausstehend" @@ -483,6 +484,7 @@ "Der neueste Protokollausschnitt wird angezeigt." "Einrichtungscode verwenden" "sticker" + "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." "steer" "Ausgewählt" "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." @@ -695,6 +697,7 @@ "In Warteschlange — wird nach erneuter Verbindung gesendet" "vor %1$s Min." "Kopplungszugriff wird geprüft" + "Limited Gateway access" "Tools werden ausgeführt..." "Genehmigung wird geprüft…" "Fotos und Clips mit diesem Telefon aufnehmen" @@ -1251,6 +1254,7 @@ "Gateway erneut verbinden" "Drittanbieter" "Bereitschaft prüfen" + "Eingeschränkt" "OpenClaw-Logo" "Modell lösen" "Mit dieser Gateway verbundene Messaging-Oberflächen." diff --git a/apps/android/app/src/main/res/values-es/strings.xml b/apps/android/app/src/main/res/values-es/strings.xml index a5a98bcbfd6d..8e7d4fbe0229 100644 --- a/apps/android/app/src/main/res/values-es/strings.xml +++ b/apps/android/app/src/main/res/values-es/strings.xml @@ -24,6 +24,7 @@ "No se pudo preparar un archivo adjunto para enviarlo." "Micrófono apagado" "Mostrar alertas de OpenClaw" + "Full" "Aprobación permitida y guardada." "Mostrar el historial de llamadas recientes" "1 pendiente" @@ -483,6 +484,7 @@ "Mostrando el fragmento de registro más reciente." "Usar código de configuración" "sticker" + "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." "steer" "Seleccionado" "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." @@ -695,6 +697,7 @@ "En cola — se enviará al volver a conectar" "hace %1$s min" "Comprobando el acceso de emparejamiento" + "Limited Gateway access" "Ejecutando herramientas..." "Comprobando aprobación…" "Captura fotos y clips desde este teléfono" @@ -1251,6 +1254,7 @@ "Reconectar Gateway" "De terceros" "Revisar preparación" + "Limitado" "Logotipo de OpenClaw" "Dejar de fijar modelo" "Superficies de mensajería conectadas a este Gateway." diff --git a/apps/android/app/src/main/res/values-fa/strings.xml b/apps/android/app/src/main/res/values-fa/strings.xml index 503aa0469272..b6b5547afdd0 100644 --- a/apps/android/app/src/main/res/values-fa/strings.xml +++ b/apps/android/app/src/main/res/values-fa/strings.xml @@ -24,6 +24,7 @@ "پیوست برای ارسال آماده نشد." "میکروفون خاموش" "نمایش هشدارهای OpenClaw" + "Full" "تأیید اجازه داده شد و ذخیره شد." "نمایش سابقه تماس‌های اخیر" "۱ در انتظار" @@ -483,6 +484,7 @@ "در حال نمایش آخرین بخش گزارش." "استفاده از کد راه‌اندازی" "sticker" + "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." "steer" "انتخاب‌شده" "Android می‌تواند یک کد راه‌اندازی موجود را اسکن یا جای‌گذاری کند، اما این gateway هنوز تولید کد راه‌اندازی را در اختیار برنامه قرار نمی‌دهد. QR/کد را روی میزبان gateway با openclaw qr تولید کنید، سپس آن را اینجا اسکن کنید یا کد راه‌اندازی را در پایین جای‌گذاری کنید." @@ -695,6 +697,7 @@ "در صف — پس از اتصال مجدد ارسال می‌شود" "%1$s دقیقه پیش" "در حال بررسی دسترسی جفت‌سازی" + "Limited Gateway access" "در حال اجرای ابزارها..." "در حال بررسی تأیید…" "گرفتن عکس و کلیپ با این تلفن" @@ -1251,6 +1254,7 @@ "اتصال دوباره به Gateway" "شخص ثالث" "بررسی آمادگی" + "محدود" "لوگوی OpenClaw" "برداشتن سنجاق مدل" "سطوح پیام‌رسانی متصل به این Gateway." diff --git a/apps/android/app/src/main/res/values-fr/strings.xml b/apps/android/app/src/main/res/values-fr/strings.xml index 89272a515f80..f3beb297f6f2 100644 --- a/apps/android/app/src/main/res/values-fr/strings.xml +++ b/apps/android/app/src/main/res/values-fr/strings.xml @@ -24,6 +24,7 @@ "Impossible de préparer une pièce jointe pour l’envoi." "Micro désactivé" "Afficher les alertes OpenClaw" + "Full" "Approbation autorisée et enregistrée." "Afficher l’historique récent des appels" "1 en attente" @@ -483,6 +484,7 @@ "Affichage du dernier bloc de journal." "Utiliser le code de configuration" "sticker" + "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." "steer" "Sélectionné" "Android peut scanner ou coller un code de configuration existant, mais ce gateway n’expose pas encore la génération de code de configuration à l’application. Générez le QR/code sur l’hôte du gateway avec openclaw qr, puis scannez-le ici ou collez le code de configuration ci-dessous." @@ -695,6 +697,7 @@ "En attente — envoi lors de la reconnexion" "il y a %1$s min" "Vérification de l’accès d’appairage" + "Limited Gateway access" "Exécution des outils..." "Vérification de l’approbation…" "Prendre des photos et enregistrer des clips avec ce téléphone" @@ -1251,6 +1254,7 @@ "Reconnecter le Gateway" "Tiers" "Vérifier l’état de préparation" + "Limité" "Logo OpenClaw" "Désépingler le modèle" "Surfaces de messagerie connectées à ce Gateway." diff --git a/apps/android/app/src/main/res/values-hi/strings.xml b/apps/android/app/src/main/res/values-hi/strings.xml index 7531638480fa..54da9c5b6fcd 100644 --- a/apps/android/app/src/main/res/values-hi/strings.xml +++ b/apps/android/app/src/main/res/values-hi/strings.xml @@ -24,6 +24,7 @@ "भेजने के लिए अटैचमेंट तैयार नहीं किया जा सका।" "माइक बंद" "OpenClaw अलर्ट दिखाएँ" + "Full" "स्वीकृति की अनुमति दी गई और सहेजी गई।" "हाल का कॉल इतिहास दिखाएँ" "1 लंबित" @@ -483,6 +484,7 @@ "नवीनतम लॉग खंड दिखाया जा रहा है।" "सेटअप कोड का उपयोग करें" "sticker" + "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." "steer" "चयनित" "Android मौजूदा सेटअप कोड को स्कैन या पेस्ट कर सकता है, लेकिन यह gateway अभी ऐप को सेटअप-कोड जनरेशन उपलब्ध नहीं कराता है। gateway host पर openclaw qr के साथ QR/कोड जनरेट करें, फिर उसे यहाँ स्कैन करें या नीचे सेटअप कोड पेस्ट करें।" @@ -695,6 +697,7 @@ "कतार में — दोबारा कनेक्ट होने पर भेजा जाएगा" "%1$sमि. पहले" "पेयरिंग एक्सेस की जाँच की जा रही है" + "Limited Gateway access" "टूल चला रहा है..." "स्वीकृति जाँची जा रही है…" "इस फ़ोन से फ़ोटो और क्लिप कैप्चर करें" @@ -1251,6 +1254,7 @@ "Gateway फिर से कनेक्ट करें" "तृतीय-पक्ष" "तत्परता की समीक्षा करें" + "सीमित" "OpenClaw लोगो" "मॉडल अनपिन करें" "इस gateway से जुड़ी मैसेजिंग सतहें।" diff --git a/apps/android/app/src/main/res/values-in/strings.xml b/apps/android/app/src/main/res/values-in/strings.xml index 642ad08029d6..d7fefe01ea27 100644 --- a/apps/android/app/src/main/res/values-in/strings.xml +++ b/apps/android/app/src/main/res/values-in/strings.xml @@ -24,6 +24,7 @@ "Tidak dapat menyiapkan lampiran untuk dikirim." "Mikrofon mati" "Tampilkan peringatan OpenClaw" + "Full" "Persetujuan diizinkan dan disimpan." "Tampilkan riwayat panggilan terbaru" "1 tertunda" @@ -483,6 +484,7 @@ "Menampilkan potongan log terbaru." "Gunakan kode penyiapan" "sticker" + "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." "steer" "Dipilih" "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." @@ -695,6 +697,7 @@ "Dalam antrean — dikirim saat terhubung kembali" "%1$s mnt lalu" "Memeriksa akses pemasangan" + "Limited Gateway access" "Menjalankan alat..." "Memeriksa persetujuan…" "Ambil foto dan klip dari ponsel ini" @@ -1251,6 +1254,7 @@ "Hubungkan ulang gateway" "Pihak ketiga" "Tinjau kesiapan" + "Terbatas" "Logo OpenClaw" "Lepas sematan model" "Permukaan perpesanan yang terhubung ke gateway ini." diff --git a/apps/android/app/src/main/res/values-it/strings.xml b/apps/android/app/src/main/res/values-it/strings.xml index 513c2ebba8fc..755e7a1dd0a9 100644 --- a/apps/android/app/src/main/res/values-it/strings.xml +++ b/apps/android/app/src/main/res/values-it/strings.xml @@ -24,6 +24,7 @@ "Impossibile preparare un allegato per l\'invio." "Microfono disattivato" "Mostra gli avvisi di OpenClaw" + "Full" "Approvazione consentita e salvata." "Mostra la cronologia delle chiamate recenti" "1 in sospeso" @@ -483,6 +484,7 @@ "Visualizzazione dell\'ultimo blocco di log." "Usa codice di configurazione" "sticker" + "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." "steer" "Selezionato" "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." @@ -695,6 +697,7 @@ "In coda — verrà inviato alla riconnessione" "%1$s min fa" "Verifica dell\'accesso all\'associazione" + "Limited Gateway access" "Esecuzione degli strumenti..." "Verifica dell\'approvazione…" "Scatta foto e registra clip con questo telefono" @@ -1251,6 +1254,7 @@ "Riconnetti gateway" "Di terze parti" "Verifica disponibilità" + "Limitato" "Logo OpenClaw" "Rimuovi modello dagli elementi in evidenza" "Superfici di messaggistica connesse a questo gateway." diff --git a/apps/android/app/src/main/res/values-ja/strings.xml b/apps/android/app/src/main/res/values-ja/strings.xml index 39c021fe0d9f..9aaf12e5fc05 100644 --- a/apps/android/app/src/main/res/values-ja/strings.xml +++ b/apps/android/app/src/main/res/values-ja/strings.xml @@ -24,6 +24,7 @@ "送信用の添付ファイルを準備できませんでした。" "マイク オフ" "OpenClawのアラートを表示" + "Full" "承認を許可して保存しました。" "最近の通話履歴を表示" "1 件保留中" @@ -483,6 +484,7 @@ "最新のログチャンクを表示しています。" "セットアップコードを使用" "sticker" + "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." "steer" "選択済み" "Android では既存のセットアップコードをスキャンまたは貼り付けできますが、この Gateway はまだアプリにセットアップコード生成機能を公開していません。Gateway ホストで openclaw qr を使用して QR/code を生成し、ここでスキャンするか、下にセットアップコードを貼り付けてください。" @@ -695,6 +697,7 @@ "キューに追加済み — 再接続時に送信されます" "%1$s分前" "ペアリングアクセスを確認中" + "Limited Gateway access" "ツールを実行中..." "承認を確認中…" "このスマートフォンで写真や動画を撮影" @@ -1251,6 +1254,7 @@ "Gateway に再接続" "サードパーティ" "準備状況を確認" + "制限あり" "OpenClaw ロゴ" "モデルのピン留めを解除" "この Gateway に接続されているメッセージング画面。" diff --git a/apps/android/app/src/main/res/values-ko/strings.xml b/apps/android/app/src/main/res/values-ko/strings.xml index 28b09ee9b71e..e9fd7e1d22f6 100644 --- a/apps/android/app/src/main/res/values-ko/strings.xml +++ b/apps/android/app/src/main/res/values-ko/strings.xml @@ -24,6 +24,7 @@ "전송할 첨부 파일을 준비할 수 없습니다." "마이크 꺼짐" "OpenClaw 알림 표시" + "Full" "승인이 허용되고 저장되었습니다." "최근 통화 기록 표시" "1개 대기 중" @@ -483,6 +484,7 @@ "최신 로그 청크를 표시하고 있습니다." "설정 코드 사용" "sticker" + "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." "steer" "선택됨" "Android는 기존 설정 코드를 스캔하거나 붙여넣을 수 있지만, 이 gateway는 아직 앱에 설정 코드 생성을 제공하지 않습니다. gateway 호스트에서 openclaw qr로 QR/코드를 생성한 다음 여기에서 스캔하거나 아래에 설정 코드를 붙여넣으세요." @@ -695,6 +697,7 @@ "대기 중 — 다시 연결되면 전송" "%1$s분 전" "페어링 액세스 확인 중" + "Limited Gateway access" "도구 실행 중..." "승인 확인 중…" "이 휴대전화에서 사진 및 클립 촬영" @@ -1251,6 +1254,7 @@ "Gateway 다시 연결" "서드 파티" "준비 상태 검토" + "제한됨" "OpenClaw 로고" "모델 고정 해제" "이 Gateway에 연결된 메시징 표면." diff --git a/apps/android/app/src/main/res/values-nl/strings.xml b/apps/android/app/src/main/res/values-nl/strings.xml index 3c5ee8f93102..41dad3dac2f7 100644 --- a/apps/android/app/src/main/res/values-nl/strings.xml +++ b/apps/android/app/src/main/res/values-nl/strings.xml @@ -24,6 +24,7 @@ "Kan een bijlage niet gereedmaken voor verzending." "Microfoon uit" "OpenClaw-waarschuwingen tonen" + "Full" "Goedkeuring toegestaan en opgeslagen." "Recente oproepgeschiedenis tonen" "1 in behandeling" @@ -483,6 +484,7 @@ "De nieuwste logchunk wordt weergegeven." "Setupcode gebruiken" "sticker" + "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." "steer" "Geselecteerd" "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." @@ -695,6 +697,7 @@ "In wachtrij — wordt verzonden zodra de verbinding is hersteld" "%1$sm geleden" "Koppeltoegang controleren" + "Limited Gateway access" "Tools worden uitgevoerd..." "Goedkeuring controleren…" "Foto\'s en clips vastleggen met deze telefoon" @@ -1251,6 +1254,7 @@ "Gateway opnieuw verbinden" "Derde partij" "Gereedheid controleren" + "Beperkt" "OpenClaw-logo" "Model losmaken" "Berichtenoppervlakken die met deze Gateway zijn verbonden." diff --git a/apps/android/app/src/main/res/values-pl/strings.xml b/apps/android/app/src/main/res/values-pl/strings.xml index 3427d6bb8ac7..9fde82c2856a 100644 --- a/apps/android/app/src/main/res/values-pl/strings.xml +++ b/apps/android/app/src/main/res/values-pl/strings.xml @@ -24,6 +24,7 @@ "Nie udało się przygotować załącznika do wysłania." "Mikrofon wyłączony" "Wyświetlanie alertów OpenClaw" + "Full" "Zatwierdzenie dozwolone i zapisane." "Wyświetlanie historii ostatnich połączeń" "1 oczekujący" @@ -483,6 +484,7 @@ "Wyświetlany jest najnowszy fragment logu." "Użyj kodu konfiguracji" "sticker" + "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." "steer" "Wybrano" "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." @@ -695,6 +697,7 @@ "W kolejce — zostanie wysłane po ponownym połączeniu" "%1$s min temu" "Sprawdzanie dostępu do parowania" + "Limited Gateway access" "Uruchamianie narzędzi..." "Sprawdzanie zatwierdzenia…" "Rób zdjęcia i nagrywaj klipy tym telefonem" @@ -1251,6 +1254,7 @@ "Połącz ponownie z Gateway" "Innej firmy" "Sprawdź gotowość" + "Ograniczone" "Logo OpenClaw" "Odepnij model" "Powierzchnie komunikacji połączone z tym Gateway." diff --git a/apps/android/app/src/main/res/values-pt-rBR/strings.xml b/apps/android/app/src/main/res/values-pt-rBR/strings.xml index 68840ba02c72..fdef6cdc60a6 100644 --- a/apps/android/app/src/main/res/values-pt-rBR/strings.xml +++ b/apps/android/app/src/main/res/values-pt-rBR/strings.xml @@ -24,6 +24,7 @@ "Não foi possível preparar um anexo para envio." "Microfone desligado" "Mostrar alertas do OpenClaw" + "Full" "Aprovação permitida e salva." "Mostrar histórico de chamadas recentes" "1 pendente" @@ -483,6 +484,7 @@ "Mostrando o bloco de log mais recente." "Usar código de configuração" "sticker" + "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." "steer" "Selecionado" "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." @@ -695,6 +697,7 @@ "Na fila — será enviado quando a conexão for restabelecida" "há %1$s min" "Verificando acesso de pareamento" + "Limited Gateway access" "Executando ferramentas..." "Verificando aprovação…" "Capturar fotos e clipes deste telefone" @@ -1251,6 +1254,7 @@ "Reconectar Gateway" "Terceiros" "Revisar prontidão" + "Limitado" "Logotipo do OpenClaw" "Desafixar modelo" "Superfícies de mensagens conectadas a este gateway." diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index a6137406e403..79bb8d148498 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -24,6 +24,7 @@ "Не удалось подготовить вложение к отправке." "Микрофон выключен" "Показывать оповещения OpenClaw" + "Full" "Одобрение разрешено и сохранено." "Показывать историю недавних вызовов" "1 в ожидании" @@ -483,6 +484,7 @@ "Показан последний фрагмент журнала." "Использовать код настройки" "sticker" + "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." "steer" "Выбрано" "Android может отсканировать или вставить существующий код настройки, но этот gateway пока не предоставляет приложению возможность создавать коды настройки. Создайте QR-код/код на хосте gateway с помощью openclaw qr, затем отсканируйте его здесь или вставьте код настройки ниже." @@ -695,6 +697,7 @@ "В очереди — будет отправлено после переподключения" "%1$s мин назад" "Проверка доступа для сопряжения" + "Limited Gateway access" "Выполняются инструменты..." "Проверка одобрения…" "Снимать фото и видео на этом телефоне" @@ -1251,6 +1254,7 @@ "Повторно подключить Gateway" "Стороннее" "Проверить готовность" + "Ограниченный доступ" "Логотип OpenClaw" "Открепить модель" "Поверхности обмена сообщениями, подключенные к этому Gateway." diff --git a/apps/android/app/src/main/res/values-sv/strings.xml b/apps/android/app/src/main/res/values-sv/strings.xml index 9c28cd5488af..c3379a11b2ff 100644 --- a/apps/android/app/src/main/res/values-sv/strings.xml +++ b/apps/android/app/src/main/res/values-sv/strings.xml @@ -24,6 +24,7 @@ "Det gick inte att förbereda en bilaga för att skickas." "Mikrofon av" "Visa aviseringar från OpenClaw" + "Full" "Godkännande tillåtet och sparat." "Visa senaste samtalshistoriken" "1 väntande" @@ -483,6 +484,7 @@ "Visar det senaste loggavsnittet." "Använd konfigurationskod" "sticker" + "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." "steer" "Vald" "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." @@ -695,6 +697,7 @@ "I kö — skickas när anslutningen återupprättas" "%1$s min sedan" "Kontrollerar parkopplingsåtkomst" + "Limited Gateway access" "Kör verktyg..." "Kontrollerar godkännande…" "Ta foton och spela in klipp med den här telefonen" @@ -1251,6 +1254,7 @@ "Återanslut Gateway" "Tredjepart" "Granska beredskap" + "Begränsad" "OpenClaw-logotyp" "Lossa modell" "Meddelandeytor anslutna till denna Gateway." diff --git a/apps/android/app/src/main/res/values-th/strings.xml b/apps/android/app/src/main/res/values-th/strings.xml index 66f1f03ec255..cb4919ab663b 100644 --- a/apps/android/app/src/main/res/values-th/strings.xml +++ b/apps/android/app/src/main/res/values-th/strings.xml @@ -24,6 +24,7 @@ "ไม่สามารถเตรียมไฟล์แนบสำหรับส่งได้" "ปิดไมค์" "แสดงการแจ้งเตือนจาก OpenClaw" + "Full" "อนุมัติและบันทึกแล้ว" "แสดงประวัติการโทรล่าสุด" "รอดำเนินการ 1 รายการ" @@ -483,6 +484,7 @@ "กำลังแสดงส่วนบันทึกล่าสุด" "ใช้รหัสตั้งค่า" "sticker" + "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." "steer" "เลือกแล้ว" "Android สามารถสแกนหรือวางรหัสตั้งค่าที่มีอยู่ได้ แต่ Gateway นี้ยังไม่เปิดให้แอปสร้างรหัสตั้งค่า สร้าง QR/รหัสบนโฮสต์ Gateway ด้วย openclaw qr จากนั้นสแกนที่นี่หรือวางรหัสตั้งค่าด้านล่าง" @@ -695,6 +697,7 @@ "อยู่ในคิว — จะส่งเมื่อเชื่อมต่ออีกครั้ง" "%1$s นาทีที่แล้ว" "กำลังตรวจสอบสิทธิ์การจับคู่" + "Limited Gateway access" "กำลังเรียกใช้เครื่องมือ..." "กำลังตรวจสอบการอนุมัติ…" "ถ่ายภาพและคลิปจากโทรศัพท์เครื่องนี้" @@ -1251,6 +1254,7 @@ "เชื่อมต่อ Gateway อีกครั้ง" "บุคคลที่สาม" "ตรวจสอบความพร้อม" + "จำกัด" "โลโก้ OpenClaw" "เลิกปักหมุดโมเดล" "พื้นผิวการส่งข้อความที่เชื่อมต่อกับ Gateway นี้" diff --git a/apps/android/app/src/main/res/values-tr/strings.xml b/apps/android/app/src/main/res/values-tr/strings.xml index 6707a2f3960e..58988c296a61 100644 --- a/apps/android/app/src/main/res/values-tr/strings.xml +++ b/apps/android/app/src/main/res/values-tr/strings.xml @@ -24,6 +24,7 @@ "Ek, gönderilmek üzere hazırlanamadı." "Mikrofon kapalı" "OpenClaw uyarılarını göster" + "Full" "Onay verildi ve kaydedildi." "Son arama geçmişini göster" "1 beklemede" @@ -483,6 +484,7 @@ "En son günlük parçası gösteriliyor." "Kurulum kodunu kullan" "sticker" + "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." "steer" "Seçildi" "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." @@ -695,6 +697,7 @@ "Sıraya alındı — yeniden bağlanıldığında gönderilecek" "%1$s dk önce" "Eşleştirme erişimi kontrol ediliyor" + "Limited Gateway access" "Araçlar çalıştırılıyor..." "Onay kontrol ediliyor…" "Bu telefondan fotoğraf ve klip çekin" @@ -1251,6 +1254,7 @@ "Gateway’i yeniden bağla" "Üçüncü taraf" "Hazır olma durumunu incele" + "Sınırlı" "OpenClaw logosu" "Modelin sabitlemesini kaldır" "Bu gateway’e bağlı mesajlaşma yüzeyleri." diff --git a/apps/android/app/src/main/res/values-uk/strings.xml b/apps/android/app/src/main/res/values-uk/strings.xml index 06154b5424d3..d080aba6bf87 100644 --- a/apps/android/app/src/main/res/values-uk/strings.xml +++ b/apps/android/app/src/main/res/values-uk/strings.xml @@ -24,6 +24,7 @@ "Не вдалося підготувати вкладення до надсилання." "Мікрофон вимкнено" "Показувати сповіщення OpenClaw" + "Full" "Схвалення дозволено та збережено." "Показувати недавню історію викликів" "1 в очікуванні" @@ -483,6 +484,7 @@ "Показано останній фрагмент журналу." "Використати код налаштування" "sticker" + "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." "steer" "Вибрано" "Android може відсканувати або вставити наявний код налаштування, але цей gateway ще не надає застосунку можливість створювати коди налаштування. Згенеруйте QR/код на хості gateway за допомогою openclaw qr, а потім відскануйте його тут або вставте код налаштування нижче." @@ -695,6 +697,7 @@ "У черзі — буде надіслано після відновлення з’єднання" "%1$s хв тому" "Перевірка доступу для спарювання" + "Limited Gateway access" "Виконуються інструменти..." "Перевірка схвалення…" "Знімати фото й відеокліпи цим телефоном" @@ -1251,6 +1254,7 @@ "Повторно підключити gateway" "Сторонній" "Перевірка готовності" + "Обмежено" "Логотип OpenClaw" "Відкріпити модель" "Поверхні обміну повідомленнями, підключені до цього Gateway." diff --git a/apps/android/app/src/main/res/values-vi/strings.xml b/apps/android/app/src/main/res/values-vi/strings.xml index ac985a5f4baf..a080ca4d2cad 100644 --- a/apps/android/app/src/main/res/values-vi/strings.xml +++ b/apps/android/app/src/main/res/values-vi/strings.xml @@ -24,6 +24,7 @@ "Không thể chuẩn bị tệp đính kèm để gửi." "Mic tắt" "Hiển thị cảnh báo của OpenClaw" + "Full" "Đã cho phép và lưu phê duyệt." "Hiển thị lịch sử cuộc gọi gần đây" "1 đang chờ" @@ -483,6 +484,7 @@ "Đang hiển thị phần nhật ký mới nhất." "Sử dụng mã thiết lập" "sticker" + "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." "steer" "Đã chọn" "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." @@ -695,6 +697,7 @@ "Đã xếp hàng — sẽ gửi khi kết nối lại" "%1$s phút trước" "Đang kiểm tra quyền ghép nối" + "Limited Gateway access" "Đang chạy công cụ..." "Đang kiểm tra phê duyệt…" "Chụp ảnh và quay đoạn phim bằng điện thoại này" @@ -1251,6 +1254,7 @@ "Kết nối lại Gateway" "Bên thứ ba" "Xem xét mức độ sẵn sàng" + "Hạn chế" "Logo OpenClaw" "Bỏ ghim mô hình" "Các bề mặt nhắn tin được kết nối với gateway này." diff --git a/apps/android/app/src/main/res/values-zh-rCN/strings.xml b/apps/android/app/src/main/res/values-zh-rCN/strings.xml index 6674c32c4d13..c428a4f59bd2 100644 --- a/apps/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rCN/strings.xml @@ -24,6 +24,7 @@ "无法准备待发送的附件。" "麦克风关闭" "显示 OpenClaw 提醒" + "Full" "已批准并保存。" "显示最近的通话记录" "1 个待处理" @@ -483,6 +484,7 @@ "正在显示最新的日志块。" "使用设置代码" "sticker" + "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." "steer" "已选择" "Android 可以扫描或粘贴现有设置码,但此 gateway 目前尚未向应用开放设置码生成功能。请在 gateway 主机上使用 openclaw qr 生成二维码/代码,然后在此扫描或在下方粘贴设置码。" @@ -695,6 +697,7 @@ "已加入队列 — 重新连接后发送" "%1$s 分钟前" "正在检查配对访问权限" + "Limited Gateway access" "正在运行工具..." "正在检查批准状态…" "使用此手机拍摄照片和视频片段" @@ -1251,6 +1254,7 @@ "重新连接 Gateway" "第三方" "检查就绪状态" + "受限" "OpenClaw 标志" "取消固定模型" "连接到此 Gateway 的消息界面。" diff --git a/apps/android/app/src/main/res/values-zh-rTW/strings.xml b/apps/android/app/src/main/res/values-zh-rTW/strings.xml index 47b4b6a84d21..f76c7df6a94a 100644 --- a/apps/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/apps/android/app/src/main/res/values-zh-rTW/strings.xml @@ -24,6 +24,7 @@ "無法準備要傳送的附件。" "麥克風關閉" "顯示 OpenClaw 提醒" + "Full" "已允許核准並儲存。" "顯示最近的通話記錄" "1 個待處理" @@ -483,6 +484,7 @@ "正在顯示最新的記錄區塊。" "使用設定代碼" "sticker" + "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." "steer" "已選取" "Android 可以掃描或貼上現有的設定代碼,但此 gateway 尚未向 App 開放設定代碼產生功能。請在 gateway 主機上使用 openclaw qr 產生 QR/code,然後在此掃描,或在下方貼上設定代碼。" @@ -695,6 +697,7 @@ "已排入佇列 — 重新連線後傳送" "%1$s 分鐘前" "正在檢查配對存取權限" + "Limited Gateway access" "正在執行工具..." "正在檢查核准狀態…" "使用此手機拍攝相片和短片" @@ -1251,6 +1254,7 @@ "重新連線 Gateway" "第三方" "檢視就緒狀態" + "受限" "OpenClaw 標誌" "取消釘選模型" "連線至此 Gateway 的訊息介面。" diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 15695e29e6d6..ac81240e61ac 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ "Could not stage an attachment for sending." "Mic off" "Show OpenClaw alerts" + "Full" "Approval allowed and saved." "Show recent call history" "1 pending" @@ -483,6 +484,7 @@ "Showing the latest log chunk." "Use setup code" "sticker" + "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." "steer" "Selected" "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." @@ -695,6 +697,7 @@ "Queued — sends when reconnected" "%1$sm ago" "Checking pairing access" + "Limited Gateway access" "Running tools..." "Checking approval…" "Capture photos and clips from this phone" @@ -1251,6 +1254,7 @@ "Reconnect gateway" "Third-party" "Review readiness" + "Limited" "OpenClaw logo" "Unpin model" "Messaging surfaces connected to this gateway." diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index 7ef509d2e9fe..a08fd3df1259 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -704,7 +704,13 @@ class GatewaySessionInvokeTest { assertEquals(emptyList(), 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 { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt index acef1a718e29..afd23981d452 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt @@ -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() diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 381e403f9c3e..623daf56e61a 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -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 diff --git a/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift b/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift index 57a3ffaa8aa7..cabc12e3c3f6 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift @@ -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")) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 6e0c7c171013..62639948ceda 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 9e7c28e21354..52511805385b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 9fe132ece1d4..18ae2acd807a 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -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", diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index d15d430d61c6..c52ae7e02634 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -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 diff --git a/docs/cli/qr.md b/docs/cli/qr.md index d846795b612a..8b0648571d06 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -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 - `--public-url `: override the public URL used in the payload - `--token `: override the gateway token the bootstrap flow authenticates against - `--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. diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 1efa87f90945..2517e353171a 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -150,7 +150,7 @@ For Tailscale or public hosts, Android requires a secure endpoint: - Preferred: Tailscale Serve / Funnel with `https://` / `wss://` - 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: diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index a6b7705b992b..400080732670 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -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: diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 1db564d5b7c4..0a74c35adade 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -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([ diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 4ef907a377ed..a6f2258f9959 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -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 { +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 { 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 { diff --git a/extensions/device-pair/pair-command-auth.test.ts b/extensions/device-pair/pair-command-auth.test.ts index 402baf3ea308..fdd584e6fda3 100644 --- a/extensions/device-pair/pair-command-auth.test.ts +++ b/extensions/device-pair/pair-command-auth.test.ts @@ -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"], }); }); diff --git a/extensions/device-pair/pair-command-auth.ts b/extensions/device-pair/pair-command-auth.ts index c053e6d9dafd..22e23aae9360 100644 --- a/extensions/device-pair/pair-command-auth.ts +++ b/extensions/device-pair/pair-command-auth.ts @@ -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, }; } diff --git a/packages/gateway-protocol/src/schema/devices.ts b/packages/gateway-protocol/src/schema/devices.ts index 75d66643268a..0fec0e6fe9d0 100644 --- a/packages/gateway-protocol/src/schema/devices.ts +++ b/packages/gateway-protocol/src/schema/devices.ts @@ -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 }, ); diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index a34e127a2f99..875c536cde79 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -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 "); }); @@ -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(); }); diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 8f3272455ccd..94868114bedd 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -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 { return renderQrTerminal(data); } @@ -108,6 +113,7 @@ export function registerQrCli(program: Command) { .option("--public-url ", "Override gateway public URL used in the setup payload") .option("--token ", "Override gateway token for setup payload") .option("--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:", diff --git a/src/gateway/server-methods/device-pair-setup.test.ts b/src/gateway/server-methods/device-pair-setup.test.ts index 9eb544f60641..c659a612c6da 100644 --- a/src/gateway/server-methods/device-pair-setup.test.ts +++ b/src/gateway/server-methods/device-pair-setup.test.ts @@ -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"); diff --git a/src/gateway/server-methods/device-pair-setup.ts b/src/gateway/server-methods/device-pair-setup.ts index f54c5b99b015..8c68721ee0a2 100644 --- a/src/gateway/server-methods/device-pair-setup.ts +++ b/src/gateway/server-methods/device-pair-setup.ts @@ -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, ); diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index b600aa5b43de..95285ef69fd1 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -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( diff --git a/src/gateway/server/ws-connection/connect-device-metadata.ts b/src/gateway/server/ws-connection/connect-device-metadata.ts index 23d76567c16e..c38d11f0e948 100644 --- a/src/gateway/server/ws-connection/connect-device-metadata.ts +++ b/src/gateway/server/ws-connection/connect-device-metadata.ts @@ -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 | 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; diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index 082ab2a39564..a09275aa7f22 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -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(); diff --git a/src/gateway/server/ws-connection/connect-device-tokens.ts b/src/gateway/server/ws-connection/connect-device-tokens.ts index 857363836bb2..372c49a60799 100644 --- a/src/gateway/server/ws-connection/connect-device-tokens.ts +++ b/src/gateway/server/ws-connection/connect-device-tokens.ts @@ -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, diff --git a/src/gateway/server/ws-connection/connect-existing-device.ts b/src/gateway/server/ws-connection/connect-existing-device.ts index 5e6946d95bd6..612f20632101 100644 --- a/src/gateway/server/ws-connection/connect-existing-device.ts +++ b/src/gateway/server/ws-connection/connect-existing-device.ts @@ -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; + } } } diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index 13c3363e1081..d69a6cc9f3d5 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -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"); diff --git a/src/infra/device-bootstrap.ts b/src/infra/device-bootstrap.ts index 218013a6fefc..df6044ef4e26 100644 --- a/src/infra/device-bootstrap.ts +++ b/src/infra/device-bootstrap.ts @@ -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] = { diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index e9eac748fd65..9de6a4f76239 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -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 () => { diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 1f3b2488721f..df7c9cdb552a 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -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, diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index e8cee941557d..e0dd532c47ed 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -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; 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, diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index a5f236f89bb8..60944de8f166 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -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, }; } diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index 5bb2d286987f..1a496d9c5e8c 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -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"], }), diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index 48a347f12bba..f2d6f3006778 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -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(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(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] : []), diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index a814ac92f00c..1580cb3283df 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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); } diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index d2e77b7e3d3d..4f11eb6e8dad 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -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; openDevicePairSetup: () => Promise; refreshDevicePairSetup: () => Promise; + setDevicePairSetupAccess: (access: DevicePairSetupAccess) => Promise; 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) => { + publish(); + await operation; + if (!disposed) { + publish(); + } + }; const isCurrentClient = (client: NonNullable) => !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; diff --git a/ui/src/e2e/mobile-pairing.e2e.test.ts b/ui/src/e2e/mobile-pairing.e2e.test.ts index 8cda5a7afa49..79210ce66f1b 100644 --- a/ui/src/e2e/mobile-pairing.e2e.test.ts +++ b/ui/src/e2e/mobile-pairing.e2e.test.ts @@ -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"); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index eb72a6e4c713..d5c36d59ca3c 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/lib/device-pair-setup.test.ts b/ui/src/lib/device-pair-setup.test.ts index 142bcc8f08dd..af26bb803eab 100644 --- a/ui/src/lib/device-pair-setup.test.ts +++ b/ui/src/lib/device-pair-setup.test.ts @@ -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; + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { @@ -14,27 +18,41 @@ function deferred() { 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(); const newResponse = deferred(); @@ -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); }); }); diff --git a/ui/src/lib/device-pair-setup.ts b/ui/src/lib/device-pair-setup.ts index b6a1de652a9f..30275d45ec9d 100644 --- a/ui/src/lib/device-pair-setup.ts +++ b/ui/src/lib/device-pair-setup.ts @@ -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(); 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("device.pair.setupCode", {}); + const result = await client.request( + "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"; } diff --git a/ui/src/pages/nodes/view-pairing.ts b/ui/src/pages/nodes/view-pairing.ts index fb1d998e3876..1f80494e3904 100644 --- a/ui/src/pages/nodes/view-pairing.ts +++ b/ui/src/pages/nodes/view-pairing.ts @@ -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) {
+
+ ${t("nodes.pairing.accessTitle")} + + +
+ ${!setup && !props.loading && !props.error + ? html` + + ` + : nothing} ${props.loading && !setup ? html`
@@ -102,6 +138,15 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
+ ${setup.accessDowngraded + ? html` +
+ ${t("nodes.pairing.transportLimitedTitle")} + ${t("nodes.pairing.transportLimitedHint")} +
+ ` + : nothing} +