fix(pairing): advertise reachable Tailnet routes (#100317)

* fix(pairing): advertise reachable tailnet routes

* fix(pairing): satisfy native and SDK checks

* fix(ios): cancel stale pairing route probes

* test(pairing): document LAN-only Serve fallback

* fix(ios): preserve pairing result initializer
This commit is contained in:
Peter Steinberger
2026-07-05 07:43:43 -07:00
committed by GitHub
parent 17c0ad86cb
commit 862de9f1a1
32 changed files with 938 additions and 192 deletions

View File

@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Mobile pairing routes:** advertise verified persistent Tailscale Serve fallbacks alongside `gateway.bind=lan` setup URLs, show every route in the Control UI and CLI, and let iOS save the first reachable endpoint. (#100280)
- **Control UI terminal tabs:** vertically center the new-session button in the terminal tab strip.
- **Control UI composer scrollbar:** show the message-field scrollbar only when the draft actually overflows its autosized height.
- **Control UI terminal cursor:** hide the browser-native contenteditable caret so the integrated terminal shows only its canvas-rendered cursor.

View File

@@ -8691,7 +8691,7 @@
},
{
"kind": "ui-modifier",
"line": 131,
"line": 132,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "Settings",
"surface": "apple",
@@ -8699,7 +8699,7 @@
},
{
"kind": "ui-modifier",
"line": 224,
"line": 229,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "Scan QR Code",
"surface": "apple",
@@ -8707,7 +8707,7 @@
},
{
"kind": "ui-modifier",
"line": 245,
"line": 250,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "Reset Onboarding?",
"surface": "apple",
@@ -8715,7 +8715,7 @@
},
{
"kind": "ui-call",
"line": 249,
"line": 254,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "Reset",
"surface": "apple",
@@ -8723,7 +8723,7 @@
},
{
"kind": "ui-call",
"line": 253,
"line": 258,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "Cancel",
"surface": "apple",
@@ -8731,7 +8731,7 @@
},
{
"kind": "ui-call",
"line": 257,
"line": 262,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "This disconnects, clears saved gateway credentials, and reopens onboarding.",
"surface": "apple",
@@ -8739,7 +8739,7 @@
},
{
"kind": "ui-modifier",
"line": 260,
"line": 265,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "QR Scanner Unavailable",
"surface": "apple",
@@ -8747,7 +8747,7 @@
},
{
"kind": "ui-call",
"line": 267,
"line": 272,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "OK",
"surface": "apple",
@@ -8755,7 +8755,7 @@
},
{
"kind": "ui-call",
"line": 321,
"line": 326,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "Enable OpenClaw Hosted Push Relay?",
"surface": "apple",
@@ -8763,7 +8763,7 @@
},
{
"kind": "ui-call",
"line": 335,
"line": 340,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "Continue",
"surface": "apple",
@@ -8771,7 +8771,7 @@
},
{
"kind": "ui-call",
"line": 343,
"line": 348,
"path": "apps/ios/Sources/Design/SettingsProTab.swift",
"source": "Not Now",
"surface": "apple",
@@ -8859,7 +8859,7 @@
},
{
"kind": "conditional-branch",
"line": 633,
"line": 664,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Configured",
"surface": "apple",
@@ -8867,7 +8867,7 @@
},
{
"kind": "conditional-branch",
"line": 633,
"line": 664,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Not configured",
"surface": "apple",
@@ -8875,7 +8875,7 @@
},
{
"kind": "conditional-branch",
"line": 698,
"line": 729,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Connect to the gateway.",
"surface": "apple",
@@ -8883,7 +8883,7 @@
},
{
"kind": "conditional-branch",
"line": 698,
"line": 729,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Gateway requests will appear here.",
"surface": "apple",
@@ -8891,7 +8891,7 @@
},
{
"kind": "conditional-branch",
"line": 745,
"line": 776,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "High",
"surface": "apple",
@@ -8899,7 +8899,7 @@
},
{
"kind": "conditional-branch",
"line": 745,
"line": 776,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Resolving",
"surface": "apple",
@@ -8907,7 +8907,7 @@
},
{
"kind": "conditional-branch",
"line": 750,
"line": 781,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "One-time approval",
"surface": "apple",
@@ -8915,7 +8915,7 @@
},
{
"kind": "conditional-branch",
"line": 750,
"line": 781,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Permission can be saved",
"surface": "apple",
@@ -8923,7 +8923,7 @@
},
{
"kind": "conditional-branch",
"line": 752,
"line": 783,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Medium",
"surface": "apple",
@@ -8931,7 +8931,7 @@
},
{
"kind": "conditional-branch",
"line": 752,
"line": 783,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Review",
"surface": "apple",
@@ -8939,7 +8939,7 @@
},
{
"kind": "conditional-branch",
"line": 773,
"line": 804,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "\\(diagnosticsIssueCount)",
"surface": "apple",
@@ -8947,7 +8947,7 @@
},
{
"kind": "conditional-branch",
"line": 784,
"line": 815,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Location off",
"surface": "apple",
@@ -8955,7 +8955,7 @@
},
{
"kind": "conditional-branch",
"line": 786,
"line": 817,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Location While Using",
"surface": "apple",
@@ -8963,7 +8963,7 @@
},
{
"kind": "conditional-branch",
"line": 788,
"line": 819,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Location While Using, effective Off",
"surface": "apple",
@@ -8971,7 +8971,7 @@
},
{
"kind": "conditional-branch",
"line": 790,
"line": 821,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Location While Using, effective Always",
"surface": "apple",
@@ -8979,7 +8979,7 @@
},
{
"kind": "conditional-branch",
"line": 792,
"line": 823,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Location Always",
"surface": "apple",
@@ -8987,7 +8987,7 @@
},
{
"kind": "conditional-branch",
"line": 794,
"line": 825,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Location Always, effective While Using",
"surface": "apple",
@@ -8995,7 +8995,7 @@
},
{
"kind": "conditional-branch",
"line": 796,
"line": 827,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Location Always, effective Off",
"surface": "apple",
@@ -9003,7 +9003,7 @@
},
{
"kind": "conditional-branch",
"line": 824,
"line": 855,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Checking iOS notification permission.",
"surface": "apple",
@@ -9011,7 +9011,7 @@
},
{
"kind": "conditional-branch",
"line": 826,
"line": 857,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "OpenClaw can show approval prompts and event alerts when the app is not active.",
"surface": "apple",
@@ -9019,7 +9019,7 @@
},
{
"kind": "conditional-branch",
"line": 828,
"line": 859,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Notifications have been denied. Enable them in iOS Settings.",
"surface": "apple",
@@ -9027,7 +9027,7 @@
},
{
"kind": "conditional-branch",
"line": 830,
"line": 861,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "Enable notifications to receive approval prompts and event alerts outside the app.",
"surface": "apple",
@@ -9035,7 +9035,7 @@
},
{
"kind": "conditional-branch",
"line": 832,
"line": 863,
"path": "apps/ios/Sources/Design/SettingsProTabActions.swift",
"source": "OpenClaw cannot determine the current notification permission state.",
"surface": "apple",
@@ -9483,7 +9483,7 @@
},
{
"kind": "ui-named-argument",
"line": 712,
"line": 713,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Scan QR",
"surface": "apple",
@@ -9491,7 +9491,7 @@
},
{
"kind": "ui-named-argument",
"line": 722,
"line": 723,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Connect",
"surface": "apple",
@@ -9499,7 +9499,7 @@
},
{
"kind": "ui-call",
"line": 731,
"line": 732,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Setup Code",
"surface": "apple",
@@ -9507,7 +9507,7 @@
},
{
"kind": "ui-call",
"line": 744,
"line": 745,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Discovered Gateways",
"surface": "apple",
@@ -9515,7 +9515,7 @@
},
{
"kind": "ui-call",
"line": 746,
"line": 747,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "No gateways found yet. Use manual setup if Bonjour is blocked.",
"surface": "apple",
@@ -9523,7 +9523,7 @@
},
{
"kind": "ui-call",
"line": 785,
"line": 786,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Manual Gateway",
"surface": "apple",
@@ -9531,7 +9531,7 @@
},
{
"kind": "ui-call",
"line": 787,
"line": 788,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Use Manual Gateway",
"surface": "apple",
@@ -9539,7 +9539,7 @@
},
{
"kind": "ui-call",
"line": 790,
"line": 791,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Host",
"surface": "apple",
@@ -9547,7 +9547,7 @@
},
{
"kind": "ui-call",
"line": 794,
"line": 795,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Port",
"surface": "apple",
@@ -9555,7 +9555,7 @@
},
{
"kind": "ui-call",
"line": 798,
"line": 799,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Use TLS",
"surface": "apple",
@@ -9563,7 +9563,7 @@
},
{
"kind": "ui-named-argument",
"line": 802,
"line": 803,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Connect Manual",
"surface": "apple",
@@ -9571,7 +9571,7 @@
},
{
"kind": "ui-call",
"line": 817,
"line": 819,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Auto-connect on launch",
"surface": "apple",
@@ -9579,7 +9579,7 @@
},
{
"kind": "ui-call",
"line": 820,
"line": 822,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Gateway Auth Token",
"surface": "apple",
@@ -9587,7 +9587,7 @@
},
{
"kind": "ui-call",
"line": 821,
"line": 823,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Gateway Password",
"surface": "apple",
@@ -9595,7 +9595,7 @@
},
{
"kind": "ui-call",
"line": 825,
"line": 827,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Reset Onboarding",
"surface": "apple",
@@ -9603,7 +9603,7 @@
},
{
"kind": "ui-call",
"line": 852,
"line": 854,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Voice Wake",
"surface": "apple",
@@ -9611,7 +9611,7 @@
},
{
"kind": "ui-call",
"line": 855,
"line": 857,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Talk Mode",
"surface": "apple",
@@ -9619,7 +9619,7 @@
},
{
"kind": "ui-call",
"line": 863,
"line": 865,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Speech Language",
"surface": "apple",
@@ -9627,7 +9627,7 @@
},
{
"kind": "ui-call",
"line": 870,
"line": 872,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Speakerphone",
"surface": "apple",
@@ -9635,7 +9635,7 @@
},
{
"kind": "ui-named-argument",
"line": 875,
"line": 877,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Wake Words",
"surface": "apple",
@@ -9643,7 +9643,7 @@
},
{
"kind": "ui-call",
"line": 895,
"line": 897,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Voice",
"surface": "apple",
@@ -9651,7 +9651,7 @@
},
{
"kind": "ui-call",
"line": 896,
"line": 898,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Provider",
"surface": "apple",
@@ -9659,7 +9659,7 @@
},
{
"kind": "ui-call",
"line": 903,
"line": 905,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Realtime Voice",
"surface": "apple",
@@ -9667,7 +9667,7 @@
},
{
"kind": "ui-call",
"line": 904,
"line": 906,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Gateway Default",
"surface": "apple",
@@ -9675,7 +9675,7 @@
},
{
"kind": "ui-call",
"line": 911,
"line": 913,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Voice Mode",
"surface": "apple",
@@ -9683,7 +9683,7 @@
},
{
"kind": "ui-call",
"line": 912,
"line": 914,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Active Voice",
"surface": "apple",
@@ -9691,7 +9691,7 @@
},
{
"kind": "ui-call",
"line": 914,
"line": 916,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Last Voice Issue",
"surface": "apple",
@@ -9699,7 +9699,7 @@
},
{
"kind": "ui-call",
"line": 916,
"line": 918,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Transport",
"surface": "apple",
@@ -9707,7 +9707,7 @@
},
{
"kind": "ui-call",
"line": 917,
"line": 919,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "API Key",
"surface": "apple",
@@ -9715,7 +9715,7 @@
},
{
"kind": "ui-call",
"line": 925,
"line": 927,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Show Talk Control",
"surface": "apple",
@@ -9723,7 +9723,7 @@
},
{
"kind": "ui-call",
"line": 928,
"line": 930,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Default Share Instruction",
"surface": "apple",
@@ -9731,7 +9731,7 @@
},
{
"kind": "ui-call",
"line": 935,
"line": 937,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Run Share Self-Test",
"surface": "apple",
@@ -9739,7 +9739,7 @@
},
{
"kind": "ui-call",
"line": 952,
"line": 954,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Discovery Debug Logs",
"surface": "apple",
@@ -9747,7 +9747,7 @@
},
{
"kind": "ui-call",
"line": 955,
"line": 957,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Debug Screen Status",
"surface": "apple",
@@ -9755,7 +9755,7 @@
},
{
"kind": "ui-named-argument",
"line": 959,
"line": 961,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Discovery Logs",
"surface": "apple",
@@ -9763,7 +9763,7 @@
},
{
"kind": "ui-call",
"line": 965,
"line": 967,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Device",
"surface": "apple",
@@ -9771,7 +9771,7 @@
},
{
"kind": "ui-call",
"line": 966,
"line": 968,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Device Name",
"surface": "apple",
@@ -9779,7 +9779,7 @@
},
{
"kind": "ui-call",
"line": 968,
"line": 970,
"path": "apps/ios/Sources/Design/SettingsProTabSections.swift",
"source": "Instance ID",
"surface": "apple",
@@ -11243,7 +11243,7 @@
},
{
"kind": "ui-call",
"line": 147,
"line": 148,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Back",
"surface": "apple",
@@ -11251,7 +11251,7 @@
},
{
"kind": "ui-call",
"line": 155,
"line": 157,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Close",
"surface": "apple",
@@ -11259,7 +11259,7 @@
},
{
"kind": "ui-call",
"line": 170,
"line": 172,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Done",
"surface": "apple",
@@ -11267,7 +11267,7 @@
},
{
"kind": "ui-modifier",
"line": 178,
"line": 180,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "QR Scanner Unavailable",
"surface": "apple",
@@ -11275,7 +11275,7 @@
},
{
"kind": "ui-call",
"line": 183,
"line": 185,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "OK",
"surface": "apple",
@@ -11283,7 +11283,7 @@
},
{
"kind": "ui-call",
"line": 212,
"line": 214,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Scan QR Code",
"surface": "apple",
@@ -11291,7 +11291,7 @@
},
{
"kind": "ui-call",
"line": 219,
"line": 221,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Cancel",
"surface": "apple",
@@ -11299,7 +11299,7 @@
},
{
"kind": "ui-call",
"line": 226,
"line": 228,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Photos",
"surface": "apple",
@@ -11307,7 +11307,7 @@
},
{
"kind": "ui-named-argument",
"line": 350,
"line": 353,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "LAN or Tailscale host",
"surface": "apple",
@@ -11315,7 +11315,7 @@
},
{
"kind": "ui-named-argument",
"line": 358,
"line": 361,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "VPS with domain",
"surface": "apple",
@@ -11323,7 +11323,7 @@
},
{
"kind": "ui-named-argument",
"line": 369,
"line": 372,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "For local iOS app development",
"surface": "apple",
@@ -11331,7 +11331,7 @@
},
{
"kind": "ui-call",
"line": 384,
"line": 388,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Continue",
"surface": "apple",
@@ -11339,7 +11339,7 @@
},
{
"kind": "ui-call",
"line": 393,
"line": 397,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Developer mode",
"surface": "apple",
@@ -11347,7 +11347,7 @@
},
{
"kind": "conditional-branch",
"line": 421,
"line": 425,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Off",
"surface": "apple",
@@ -11355,7 +11355,7 @@
},
{
"kind": "conditional-branch",
"line": 421,
"line": 425,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "On",
"surface": "apple",
@@ -11363,7 +11363,7 @@
},
{
"kind": "ui-call",
"line": 428,
"line": 432,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Mode",
"surface": "apple",
@@ -11371,7 +11371,7 @@
},
{
"kind": "ui-call",
"line": 429,
"line": 433,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Discovery",
"surface": "apple",
@@ -11379,7 +11379,7 @@
},
{
"kind": "ui-call",
"line": 431,
"line": 435,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Progress",
"surface": "apple",
@@ -11387,7 +11387,7 @@
},
{
"kind": "ui-call",
"line": 433,
"line": 437,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Status",
"surface": "apple",
@@ -11395,7 +11395,7 @@
},
{
"kind": "ui-call",
"line": 452,
"line": 456,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Choose a mode first.",
"surface": "apple",
@@ -11403,7 +11403,7 @@
},
{
"kind": "ui-call",
"line": 457,
"line": 461,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Back to Mode Selection",
"surface": "apple",
@@ -11411,7 +11411,7 @@
},
{
"kind": "ui-call",
"line": 469,
"line": 473,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "No gateways found yet.",
"surface": "apple",
@@ -11419,7 +11419,7 @@
},
{
"kind": "ui-call",
"line": 494,
"line": 498,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Resolving…",
"surface": "apple",
@@ -11427,7 +11427,7 @@
},
{
"kind": "ui-call",
"line": 510,
"line": 514,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Restart Discovery",
"surface": "apple",
@@ -11435,7 +11435,7 @@
},
{
"kind": "ui-call",
"line": 516,
"line": 520,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Discovered Gateways",
"surface": "apple",
@@ -11443,7 +11443,7 @@
},
{
"kind": "ui-named-argument",
"line": 520,
"line": 524,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Manual Fallback",
"surface": "apple",
@@ -11451,7 +11451,7 @@
},
{
"kind": "ui-named-argument",
"line": 525,
"line": 529,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Domain Settings",
"surface": "apple",
@@ -11459,7 +11459,7 @@
},
{
"kind": "ui-call",
"line": 540,
"line": 544,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Developer Local",
"surface": "apple",
@@ -11467,7 +11467,7 @@
},
{
"kind": "ui-call",
"line": 543,
"line": 547,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.",
"surface": "apple",
@@ -11475,7 +11475,7 @@
},
{
"kind": "ui-call",
"line": 567,
"line": 571,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway rejected credentials. Scan a fresh QR code or update token/password.",
"surface": "apple",
@@ -11483,7 +11483,7 @@
},
{
"kind": "ui-call",
"line": 571,
"line": 575,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Auth token looks valid.",
"surface": "apple",
@@ -11491,7 +11491,7 @@
},
{
"kind": "ui-call",
"line": 585,
"line": 589,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Resume After Approval",
"surface": "apple",
@@ -11499,7 +11499,7 @@
},
{
"kind": "ui-call",
"line": 591,
"line": 595,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Pairing Approval",
"surface": "apple",
@@ -11507,7 +11507,7 @@
},
{
"kind": "ui-call-concatenated",
"line": 601,
"line": 605,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Approve this device on the gateway.\n1) `\\(commandLine)`\n2) `/pair approve` in your OpenClaw chat\n\\(requestLine)\nOpenClaw will also retry automatically when you return to this app.",
"surface": "apple",
@@ -11515,7 +11515,7 @@
},
{
"kind": "ui-call",
"line": 615,
"line": 619,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Scan QR Code Again",
"surface": "apple",
@@ -11523,7 +11523,7 @@
},
{
"kind": "ui-call",
"line": 628,
"line": 632,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Retry Connection",
"surface": "apple",
@@ -11531,7 +11531,7 @@
},
{
"kind": "ui-call",
"line": 661,
"line": 665,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Go to Chat",
"surface": "apple",
@@ -11539,7 +11539,7 @@
},
{
"kind": "ui-call",
"line": 675,
"line": 679,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Paste setup code",
"surface": "apple",
@@ -11547,7 +11547,7 @@
},
{
"kind": "ui-call",
"line": 690,
"line": 695,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Applying...",
"surface": "apple",
@@ -11555,7 +11555,7 @@
},
{
"kind": "ui-call",
"line": 694,
"line": 699,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Apply Setup Code",
"surface": "apple",
@@ -11563,7 +11563,7 @@
},
{
"kind": "ui-call",
"line": 709,
"line": 714,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Setup Code",
"surface": "apple",
@@ -11571,7 +11571,7 @@
},
{
"kind": "ui-call",
"line": 712,
"line": 717,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Use this if you received a setup code instead of a QR code.",
"surface": "apple",
@@ -11579,7 +11579,7 @@
},
{
"kind": "ui-call",
"line": 719,
"line": 724,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Host",
"surface": "apple",
@@ -11587,7 +11587,7 @@
},
{
"kind": "ui-call",
"line": 723,
"line": 728,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Port",
"surface": "apple",
@@ -11595,7 +11595,7 @@
},
{
"kind": "ui-call",
"line": 726,
"line": 731,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Use TLS",
"surface": "apple",
@@ -11603,7 +11603,7 @@
},
{
"kind": "ui-call",
"line": 727,
"line": 732,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Discovery Domain (optional)",
"surface": "apple",
@@ -11611,7 +11611,7 @@
},
{
"kind": "ui-call",
"line": 732,
"line": 737,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway Auth Token",
"surface": "apple",
@@ -11619,7 +11619,7 @@
},
{
"kind": "ui-call",
"line": 735,
"line": 740,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway Password",
"surface": "apple",
@@ -11627,7 +11627,7 @@
},
{
"kind": "ui-call",
"line": 779,
"line": 784,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Connecting…",
"surface": "apple",

View File

@@ -46,6 +46,7 @@ struct SettingsProTab: View {
@State var gatewayPassword = ""
@State var manualGatewayPortText = ""
@State var setupStatusText: String?
@State var setupAttemptID: UUID?
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State var defaultShareInstruction = ""
@@ -143,6 +144,9 @@ struct SettingsProTab: View {
private func settingsLifecycle(_ content: some View) -> some View {
content
.onDisappear {
self.invalidateGatewaySetupAttempt()
}
.task {
self.previousLocationModeRaw = self.locationModeRaw
self.syncSettingsState()
@@ -192,6 +196,7 @@ struct SettingsProTab: View {
self.syncAfterOnboardingReset()
}
.onChange(of: self.navigationPath) { _, _ in
self.invalidateGatewaySetupAttempt()
self.notifyRouteChange()
}
}

View File

@@ -191,7 +191,7 @@ extension SettingsProTab {
}
func syncAfterOnboardingReset() {
self.connectingGatewayID = nil
self.invalidateGatewaySetupAttempt()
self.setupStatusText = nil
self.stagedGatewaySetupLink = nil
self.pendingManualAuthOverride = nil
@@ -210,8 +210,10 @@ extension SettingsProTab {
}
func applySetupCodeAndConnect() async {
guard let attemptID = self.beginGatewaySetupAttempt() else { return }
defer { self.finishGatewaySetupAttempt(attemptID) }
self.setupStatusText = nil
guard self.applySetupCode() else { return }
guard await self.applySetupCode(attemptID: attemptID) else { return }
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.resolvedManualPort(host: host) != nil else {
self.setupStatusText = "Failed: invalid port"
@@ -219,7 +221,7 @@ extension SettingsProTab {
}
guard await self.preflightGateway(host: host) else { return }
self.setupStatusText = "Setup code applied. Connecting..."
await self.connectManual()
await self.connectManual(setupAttemptID: attemptID)
}
func applyGatewaySetupLink(_ link: GatewayConnectDeepLink) {
@@ -231,7 +233,7 @@ extension SettingsProTab {
}
@discardableResult
func applySetupCode() -> Bool {
func applySetupCode(attemptID: UUID) async -> Bool {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
let stagedLink = self.stagedGatewaySetupLink
guard !raw.isEmpty || stagedLink != nil else {
@@ -247,10 +249,12 @@ extension SettingsProTab {
return false
}
guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
guard let parsedLink = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return false
}
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return false }
self.stagedGatewaySetupLink = nil
self.applyGatewayLink(link)
return true
@@ -286,17 +290,16 @@ extension SettingsProTab {
func openGatewayQRScanner() {
self.appModel.disconnectGateway()
self.connectingGatewayID = nil
self.invalidateGatewaySetupAttempt()
self.setupStatusText = "Opening QR scanner..."
self.showQRScanner = true
}
func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
self.showQRScanner = false
guard let attemptID = self.beginGatewaySetupAttempt() else { return }
self.setupCode = ""
self.applyGatewayLink(link)
self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..."
Task { await self.connectAfterScannedGatewayLink() }
Task { await self.connectAfterScannedGatewayLink(link, attemptID: attemptID) }
}
func handleScannedSetupCode(_ code: String) {
@@ -308,17 +311,27 @@ extension SettingsProTab {
self.appModel.enterAppleReviewDemoMode()
}
func connectAfterScannedGatewayLink() async {
func connectAfterScannedGatewayLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async {
defer { self.finishGatewaySetupAttempt(attemptID) }
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
self.applyGatewayLink(link)
self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..."
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.resolvedManualPort(host: host) != nil else {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host) else { return }
await self.connectManual()
await self.connectManual(setupAttemptID: attemptID)
}
func connectManual() async {
func connectManual(setupAttemptID: UUID? = nil) async {
if let setupAttemptID {
guard self.setupAttemptID == setupAttemptID else { return }
} else {
self.invalidateGatewaySetupAttempt()
}
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.setupStatusText = "Failed: host required"
@@ -355,7 +368,7 @@ extension SettingsProTab {
}
func resetOnboarding() {
self.connectingGatewayID = nil
self.invalidateGatewaySetupAttempt()
self.setupStatusText = nil
self.setupCode = ""
self.gatewayAutoConnect = false
@@ -371,6 +384,24 @@ extension SettingsProTab {
self.onboardingRequestID += 1
}
func beginGatewaySetupAttempt() -> UUID? {
guard self.connectingGatewayID == nil else { return nil }
let attemptID = UUID()
self.setupAttemptID = attemptID
self.connectingGatewayID = "setup-code"
return attemptID
}
func finishGatewaySetupAttempt(_ attemptID: UUID) {
guard self.setupAttemptID == attemptID else { return }
self.invalidateGatewaySetupAttempt()
}
func invalidateGatewaySetupAttempt() {
self.setupAttemptID = nil
self.connectingGatewayID = nil
}
func handleLocationModeChange(_ newValue: String) {
guard !self.isChangingLocationMode else { return }
guard newValue != self.previousLocationModeRaw else { return }

View File

@@ -708,6 +708,7 @@ extension SettingsProTab {
.font(OpenClawType.body)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(self.connectingGatewayID != nil)
self.gatewayActionButton(
title: "Scan QR",
icon: "qrcode.viewfinder",
@@ -809,6 +810,7 @@ extension SettingsProTab {
Task { await self.connectManual() }
}
}
.disabled(self.setupAttemptID != nil)
}
var gatewayAdvancedCard: some View {

View File

@@ -37,6 +37,10 @@ private enum GatewayTLSFingerprintProbeBudget {
static let tlsHandshakeTimeoutSeconds = 10.0
}
private enum GatewaySetupRouteProbeBudget {
static let tcpConnectTimeoutSeconds = 2.0
}
private func defaultGatewayTCPReachabilityProbe(
host: String,
port: Int,
@@ -215,6 +219,25 @@ final class GatewayConnectionController {
self.discovery.setDebugLoggingEnabled(enabled)
}
func selectReachableSetupLink(_ link: GatewayConnectDeepLink) async -> GatewayConnectDeepLink {
let endpoints = link.connectionEndpoints
guard endpoints.count > 1 else { return link }
// Probe before persisting: a setup code may carry LAN and Tailnet routes,
// but only the route reachable from the phone should become its saved endpoint.
self.requestLocalNetworkAccess(reason: "setup_route_probe")
for (index, endpoint) in endpoints.enumerated() {
let reachable = await self.tcpReachabilityProbe(
endpoint.host,
endpoint.port,
GatewaySetupRouteProbeBudget.tcpConnectTimeoutSeconds,
"ai.openclaw.gateway.setup-route-\(index)")
if reachable {
return link.selectingEndpoint(endpoint)
}
}
return link
}
func requestLocalNetworkAccess(reason: String) {
guard self.discoveryEnabled else {
self.discovery.stop()

View File

@@ -70,6 +70,7 @@ struct OnboardingWizardView: View {
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State private var setupCode: String = ""
@State private var setupCodeStatus: String?
@State private var setupAttemptID: UUID?
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
let allowSkip: Bool
@@ -150,6 +151,7 @@ struct OnboardingWizardView: View {
.font(OpenClawType.subheadSemiBold)
} else if self.allowSkip {
Button {
self.invalidateSetupAttempt()
self.onClose()
} label: {
Text("Close")
@@ -269,6 +271,7 @@ struct OnboardingWizardView: View {
self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear")
}
.onDisappear {
self.invalidateSetupAttempt()
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = nil
}
@@ -332,10 +335,10 @@ struct OnboardingWizardView: View {
OnboardingWelcomeStep(
statusLine: self.statusLine,
onScanQRCode: {
self.statusLine = "Opening QR scanner…"
self.showQRScanner = true
self.openQRScannerFromOnboarding()
},
onManualSetup: {
self.invalidateSetupAttempt()
self.step = .mode
})
}
@@ -376,6 +379,7 @@ struct OnboardingWizardView: View {
Text("Connection Mode")
.font(OpenClawType.captionSemiBold)
}
.disabled(self.connectingGatewayID != nil)
Section {
Button {
@@ -385,7 +389,7 @@ struct OnboardingWizardView: View {
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.selectedMode == nil)
.disabled(self.selectedMode == nil || self.connectingGatewayID != nil)
}
}
@@ -676,6 +680,7 @@ extension OnboardingWizardView {
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(OpenClawType.subhead)
.disabled(self.connectingGatewayID != nil)
.onSubmit {
Task { await self.applySetupCodeAndConnect() }
}
@@ -803,29 +808,41 @@ extension OnboardingWizardView {
return
}
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
guard let parsedLink = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL."
return
}
self.connectingGatewayID = "setup-code"
guard let attemptID = self.beginSetupAttempt() else { return }
defer { self.finishSetupAttempt(attemptID) }
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
self.applyGatewayLink(link)
self.setupCode = ""
self.setupCodeStatus = "Setup code applied. Connecting..."
self.connectMessage = "Connecting via setup code..."
self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)..."
self.step = .connect
await self.connectManual()
await self.connectManual(setupAttemptID: attemptID)
}
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
self.applyGatewayLink(link)
self.setupCodeStatus = nil
self.showQRScanner = false
guard let attemptID = self.beginSetupAttempt() else { return }
self.setupCodeStatus = nil
Task { await self.connectScannedLink(link, attemptID: attemptID) }
}
private func connectScannedLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async {
defer { self.finishSetupAttempt(attemptID) }
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
self.applyGatewayLink(link)
self.connectMessage = "Connecting via QR code..."
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)..."
self.step = .connect
Task { await self.connectManual() }
await self.connectManual(setupAttemptID: attemptID)
}
private func applyGatewayLink(_ link: GatewayConnectDeepLink) {
@@ -856,7 +873,7 @@ extension OnboardingWizardView {
private func handleScannedSetupCode(_ code: String) {
guard AppleReviewDemoMode.isSetupCode(code) else { return }
self.showQRScanner = false
self.connectingGatewayID = nil
self.invalidateSetupAttempt()
self.connectMessage = "Apple Review demo mode enabled."
self.statusLine = "Apple Review demo mode enabled."
self.selectedMode = .homeNetwork
@@ -866,7 +883,7 @@ extension OnboardingWizardView {
private func openQRScannerFromOnboarding() {
// Stop active reconnect loops before scanning new credentials.
self.appModel.disconnectGateway()
self.connectingGatewayID = nil
self.invalidateSetupAttempt()
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
@@ -982,11 +999,29 @@ extension OnboardingWizardView {
private func navigateBack() {
guard let target = step.previous else { return }
self.connectingGatewayID = nil
self.invalidateSetupAttempt()
self.connectMessage = nil
self.step = target
}
private func beginSetupAttempt() -> UUID? {
guard self.connectingGatewayID == nil else { return nil }
let attemptID = UUID()
self.setupAttemptID = attemptID
self.connectingGatewayID = "setup-code"
return attemptID
}
private func finishSetupAttempt(_ attemptID: UUID) {
guard self.setupAttemptID == attemptID else { return }
self.invalidateSetupAttempt()
}
private func invalidateSetupAttempt() {
self.setupAttemptID = nil
self.connectingGatewayID = nil
}
private var canConnectManual: Bool {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
@@ -1109,7 +1144,12 @@ extension OnboardingWizardView {
return !tailnetDns.isEmpty
}
private func connectManual() async {
private func connectManual(setupAttemptID: UUID? = nil) async {
if let setupAttemptID {
guard self.setupAttemptID == setupAttemptID else { return }
} else {
self.invalidateSetupAttempt()
}
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return }
self.connectingGatewayID = "manual"

View File

@@ -1,8 +1,8 @@
import Foundation
import Network
import OpenClawKit
import Testing
import os
import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionSecurityTests {
@@ -37,7 +37,7 @@ import os
GatewayTLSStore.clearFingerprint(stableID: stableID)
}
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
@Test @MainActor func `discovered TLS params prefers stored pin over advertised TXT`() {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
@@ -57,7 +57,7 @@ import os
#expect(params?.allowTOFU == false)
}
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
@Test @MainActor func `discovered TLS params does not trust advertised fingerprint`() {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
@@ -75,7 +75,7 @@ import os
#expect(params?.allowTOFU == false)
}
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
@Test @MainActor func `autoconnect requires stored pin for discovered gateways`() {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
@@ -104,7 +104,7 @@ import os
#expect(controller._test_didAutoConnect() == false)
}
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
@Test @MainActor func `manual connections force TLS for non loopback hosts`() {
let controller = self.makeController()
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
@@ -120,7 +120,7 @@ import os
#expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false)
}
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
@Test @MainActor func `manual connections allow private lan plaintext`() {
let controller = self.makeController()
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
@@ -131,7 +131,7 @@ import os
#expect(controller._test_resolveManualUseTLS(host: "fd00::1", useTLS: false) == false)
}
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
@Test @MainActor func `manual default port uses 443 only for tailnet TLS hosts`() {
let controller = self.makeController()
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
@@ -140,7 +140,55 @@ import os
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789)
}
@Test @MainActor func manualFirstUseTLSProbeShowsTrustPromptAfterFingerprintCapture() async {
@Test @MainActor func `setup route selection falls back to reachable tailnet endpoint`() async {
let probes = OSAllocatedUnfairLock(initialState: [(String, Int)]())
let controller = GatewayConnectionController(
appModel: NodeAppModel(),
startDiscovery: false,
tcpReachabilityProbe: { host, port, _, _ in
probes.withLock { $0.append((host, port)) }
return host.hasSuffix(".ts.net")
})
let link = GatewayConnectDeepLink(
host: "192.168.139.3",
port: 18789,
tls: false,
bootstrapToken: "boot",
token: nil,
password: nil,
fallbackEndpoints: [
.init(host: "clawmac.tail.ts.net", port: 8443, tls: true),
])
let selected = await controller.selectReachableSetupLink(link)
#expect(selected.host == "clawmac.tail.ts.net")
#expect(selected.port == 8443)
#expect(selected.tls)
#expect(selected.bootstrapToken == "boot")
#expect(probes.withLock { $0.map(\.0) } == ["192.168.139.3", "clawmac.tail.ts.net"])
}
@Test @MainActor func `setup route selection keeps legacy endpoint when every probe fails`() async {
let controller = GatewayConnectionController(
appModel: NodeAppModel(),
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in false })
let link = GatewayConnectDeepLink(
host: "192.168.139.3",
port: 18789,
tls: false,
bootstrapToken: "boot",
token: nil,
password: nil,
fallbackEndpoints: [
.init(host: "clawmac.tail.ts.net", port: 8443, tls: true),
])
#expect(await controller.selectReachableSetupLink(link) == link)
}
@Test @MainActor func `manual first use TLS probe shows trust prompt after fingerprint capture`() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
@@ -162,7 +210,7 @@ import os
#expect(appModel.gatewayStatusText == "Verify gateway TLS fingerprint")
}
@Test @MainActor func manualFirstUseTLSProbeSkipsTLSWhenTCPIsUnreachable() async {
@Test @MainActor func `manual first use TLS probe skips TLS when TCP is unreachable`() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
@@ -187,7 +235,7 @@ import os
#expect(appModel.gatewayStatusText == "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.")
}
@Test @MainActor func manualFirstUseTLSProbeReportsHandshakeTimeoutWithoutTrustPrompt() async {
@Test @MainActor func `manual first use TLS probe reports handshake timeout without trust prompt`() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
@@ -218,7 +266,7 @@ import os
#expect(appModel.gatewayStatusText.contains("\(host):\(port)"))
}
@Test @MainActor func discoveredFirstUseTLSProbeFailureClearsStaleTrustPrompt() async {
@Test @MainActor func `discovered first use TLS probe failure clears stale trust prompt`() async {
let staleHost = "stale-\(UUID().uuidString).example.com"
let stalePort = 18789
let staleStableID = "manual|\(staleHost.lowercased())|\(stalePort)"
@@ -261,7 +309,7 @@ import os
#expect(appModel.gatewayStatusText == message)
}
@Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async {
@Test @MainActor func `clear all TLS fingerprints removes stored pins`() {
let stableID1 = "test|\(UUID().uuidString)"
let stableID2 = "test|\(UUID().uuidString)"
defer { GatewayTLSStore.clearAllFingerprints() }
@@ -278,7 +326,7 @@ import os
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil)
}
@Test func trustedPinMismatchCanBeRecoveredByReplacingStoredPin() {
@Test func `trusted pin mismatch can be recovered by replacing stored pin`() {
let stableID = "test|\(UUID().uuidString)"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.saveFingerprint("old", stableID: stableID)
@@ -305,7 +353,7 @@ import os
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new")
}
@Test func untrustedPinMismatchCannotBeRecoveredInApp() {
@Test func `untrusted pin mismatch cannot be recovered in app`() {
let error = GatewayTLSValidationError(
failure: GatewayTLSValidationFailure(
kind: .pinMismatch,

View File

@@ -836,6 +836,36 @@ struct RootTabsSourceGuardTests {
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
}
@Test func `setup route probes yield to newer manual actions`() throws {
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let welcomeStep = try Self.extract(
onboardingSource,
from: "private var welcomeStep: some View",
to: "@ViewBuilder\n private var modeStep")
#expect(welcomeStep.contains("self.openQRScannerFromOnboarding()"))
#expect(welcomeStep.contains("self.invalidateSetupAttempt()"))
let onboardingManualConnect = try Self.extract(
onboardingSource,
from: "private func connectManual(setupAttemptID: UUID? = nil) async",
to: "private func connectCurrentManualGateway")
#expect(onboardingManualConnect.contains("guard self.setupAttemptID == setupAttemptID else { return }"))
#expect(onboardingManualConnect.contains("self.invalidateSetupAttempt()"))
#expect(onboardingSource.contains("await self.connectManual(setupAttemptID: attemptID)"))
let settingsManualConnect = try Self.extract(
actionsSource,
from: "func connectManual(setupAttemptID: UUID? = nil) async",
to: "func preflightGateway")
#expect(settingsManualConnect.contains("guard self.setupAttemptID == setupAttemptID else { return }"))
#expect(settingsManualConnect.contains("self.invalidateGatewaySetupAttempt()"))
#expect(actionsSource.contains("await self.connectManual(setupAttemptID: attemptID)"))
#expect(sectionsSource.contains(".disabled(self.setupAttemptID != nil)"))
}
@Test func `local network access is requested from visible gateway flows`() throws {
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)

View File

@@ -11,8 +11,21 @@ public enum DeepLinkRoute: Sendable, Equatable {
}
public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
private static let maximumSetupEndpoints = 8
private enum CodingKeys: String, CodingKey {
case host
case port
case tls
case bootstrapToken
case token
case password
case fallbackEndpoints
}
private struct SetupPayload: Decodable {
let url: String?
let urls: [String]?
let host: String?
let port: Int?
let tls: Bool?
@@ -27,14 +40,37 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let bootstrapToken: String?
public let token: String?
public let password: String?
public let fallbackEndpoints: [GatewayConnectEndpoint]
public init(host: String, port: Int, tls: Bool, bootstrapToken: String?, token: String?, password: String?) {
public init(
host: String,
port: Int,
tls: Bool,
bootstrapToken: String?,
token: String?,
password: String?,
fallbackEndpoints: [GatewayConnectEndpoint] = [])
{
self.host = host
self.port = port
self.tls = tls
self.bootstrapToken = bootstrapToken
self.token = token
self.password = password
self.fallbackEndpoints = fallbackEndpoints
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.host = try container.decode(String.self, forKey: .host)
self.port = try container.decode(Int.self, forKey: .port)
self.tls = try container.decode(Bool.self, forKey: .tls)
self.bootstrapToken = try container.decodeIfPresent(String.self, forKey: .bootstrapToken)
self.token = try container.decodeIfPresent(String.self, forKey: .token)
self.password = try container.decodeIfPresent(String.self, forKey: .password)
self.fallbackEndpoints = try container.decodeIfPresent(
[GatewayConnectEndpoint].self,
forKey: .fallbackEndpoints) ?? []
}
public var websocketURL: URL? {
@@ -42,6 +78,20 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
return URL(string: "\(scheme)://\(self.host):\(self.port)")
}
public var connectionEndpoints: [GatewayConnectEndpoint] {
[.init(host: self.host, port: self.port, tls: self.tls)] + self.fallbackEndpoints
}
public func selectingEndpoint(_ endpoint: GatewayConnectEndpoint) -> GatewayConnectDeepLink {
.init(
host: endpoint.host,
port: endpoint.port,
tls: endpoint.tls,
bootstrapToken: self.bootstrapToken,
token: self.token,
password: self.password)
}
/// Parse a gateway setup input from the QR/scanner/manual entry surfaces.
///
/// Accepted inputs are:
@@ -77,12 +127,13 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
/// - copied text/message content containing one or more extractable setup-code candidates
///
/// Accepted payload shapes are:
/// - `{url, bootstrapToken?, token?, password?}`
/// - `{url, urls?, bootstrapToken?, token?, password?}`
/// - `{host, port?, tls?, bootstrapToken?, token?, password?}`
///
/// URL-based payloads provide the gateway WebSocket URL via `url`. Host-based payloads
/// provide `host` plus optional `port` and `tls`. In both cases, the optional
/// `bootstrapToken`, `token`, and `password` fields are also supported.
/// URL-based payloads provide the primary gateway WebSocket URL via `url`, with optional
/// ordered candidates in `urls`. Host-based payloads provide `host` plus optional `port`
/// and `tls`. In both cases, the optional `bootstrapToken`, `token`, and `password` fields
/// are also supported.
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
@@ -106,15 +157,36 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
private static func decodeSetupPayload(from data: Data) -> GatewayConnectDeepLink? {
guard let payload = try? JSONDecoder().decode(SetupPayload.self, from: data) else { return nil }
if let urlString = payload.url?.trimmingCharacters(in: .whitespacesAndNewlines),
!urlString.isEmpty
{
var urlCandidates = payload.url.map { [$0] } ?? []
for candidate in payload.urls ?? [] {
guard urlCandidates.count < self.maximumSetupEndpoints else { break }
if !urlCandidates.contains(candidate) {
urlCandidates.append(candidate)
}
}
var seenURLs = Set<String>()
let links = urlCandidates.compactMap { rawURL -> GatewayConnectDeepLink? in
let url = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !url.isEmpty, seenURLs.insert(url).inserted else { return nil }
return self.fromGatewayURLString(
urlString,
url,
bootstrapToken: payload.bootstrapToken,
token: payload.token,
password: payload.password)
}
if let primary = links.first {
let fallbacks = links.dropFirst().map {
GatewayConnectEndpoint(host: $0.host, port: $0.port, tls: $0.tls)
}
return GatewayConnectDeepLink(
host: primary.host,
port: primary.port,
tls: primary.tls,
bootstrapToken: primary.bootstrapToken,
token: primary.token,
password: primary.password,
fallbackEndpoints: fallbacks)
}
guard let host = payload.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
@@ -185,6 +257,18 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
}
public struct GatewayConnectEndpoint: Codable, Sendable, Equatable {
public let host: String
public let port: Int
public let tls: Bool
public init(host: String, port: Int, tls: Bool) {
self.host = host
self.port = port
self.tls = tls
}
}
public struct AgentDeepLink: Codable, Sendable, Equatable {
public let message: String
public let sessionKey: String?

View File

@@ -7541,6 +7541,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
public let setupcode: String
public let qrdataurl: String?
public let gatewayurl: String
public let gatewayurls: [String]?
public let auth: AnyCodable
public let urlsource: String
@@ -7548,12 +7549,14 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
setupcode: String,
qrdataurl: String?,
gatewayurl: String,
gatewayurls: [String]? = nil,
auth: AnyCodable,
urlsource: String)
{
self.setupcode = setupcode
self.qrdataurl = qrdataurl
self.gatewayurl = gatewayurl
self.gatewayurls = gatewayurls
self.auth = auth
self.urlsource = urlsource
}
@@ -7562,6 +7565,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
case setupcode = "setupCode"
case qrdataurl = "qrDataUrl"
case gatewayurl = "gatewayUrl"
case gatewayurls = "gatewayUrls"
case auth
case urlsource = "urlSource"
}

View File

@@ -18,6 +18,17 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? {
}
@Suite struct DeepLinksSecurityTests {
@Test func setupResultInitializerKeepsLegacySignature() {
let result = DevicePairSetupCodeResult(
setupcode: "code",
qrdataurl: nil,
gatewayurl: "wss://gateway.example.com",
auth: AnyCodable("token"),
urlsource: "config")
#expect(result.gatewayurls == nil)
}
@Test func dashboardDeepLinkParses() {
let url = URL(string: "openclaw://dashboard")!
#expect(DeepLinkParser.parse(url) == .dashboard)
@@ -118,6 +129,56 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? {
password: nil))
}
@Test func setupCodeParsesOrderedGatewayFallbacks() throws {
let payload = #"{"url":"ws://192.168.1.20:18789","urls":["ws://192.168.1.20:18789","wss://gateway.tailnet.ts.net:8443"],"bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.connectionEndpoints == [
.init(host: "192.168.1.20", port: 18789, tls: false),
.init(host: "gateway.tailnet.ts.net", port: 8443, tls: true),
])
#expect(try link?.selectingEndpoint(#require(link?.connectionEndpoints[1])) == .init(
host: "gateway.tailnet.ts.net",
port: 8443,
tls: true,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func legacyEncodedGatewayLinkDecodesWithoutFallbacks() throws {
let payload = #"{"host":"gateway.tailnet.ts.net","port":443,"tls":true}"#
let link = try JSONDecoder().decode(
GatewayConnectDeepLink.self,
from: Data(payload.utf8))
#expect(link.fallbackEndpoints.isEmpty)
}
@Test func setupCodeDropsInsecureGatewayFallbacks() {
let payload = #"{"url":"ws://attacker.example:18789","urls":["ws://attacker.example:18789","wss://gateway.tailnet.ts.net"],"bootstrapToken":"tok"}"#
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init(
host: "gateway.tailnet.ts.net",
port: 443,
tls: true,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func setupCodeCapsGatewayEndpoints() throws {
let urls = (0..<10).map { "wss://gateway-\($0).example.com" }
let data = try JSONSerialization.data(withJSONObject: ["url": urls[0], "urls": urls])
let payload = try #require(String(data: data, encoding: .utf8))
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link?.connectionEndpoints.count == 8)
#expect(link?.connectionEndpoints.last?.host == "gateway-7.example.com")
}
@Test func setupCodeRejectsTailnetPlaintextWs() {
let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"#
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)

View File

@@ -1,2 +1,2 @@
05e2bd38087611d3696275fd1c6ae52ab2843fe983fe8bf104c66e54e2ce205a plugin-sdk-api-baseline.json
5a94d62be4cd9f509593c67d9df7988c1878596d0010ffb11072b29a27301e9a plugin-sdk-api-baseline.jsonl
f333778230502df8e9f417cdb81997b5baff12755ffc7148f2d1503cdc835616 plugin-sdk-api-baseline.json
666e5be7db9cb2708267e0e0d509cedd19daf3188abd83171e74f5c2aee3f68e plugin-sdk-api-baseline.jsonl

View File

@@ -140,6 +140,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire
The setup code is a base64-encoded JSON payload that contains:
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`)
- `urls`: when available, the ordered LAN/Tailnet routes the mobile app can try
- `bootstrapToken`: a single-use bootstrap token for the initial pairing handshake (expires after 10 minutes; `expiresAtMs` is included in the payload)
Run `/pair cleanup` to invalidate unused setup codes once pairing finishes.
@@ -164,6 +165,13 @@ 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.
For `gateway.bind=lan` setup URLs, OpenClaw detects persistent Tailscale Serve
HTTPS roots that proxy the active Gateway's loopback port and advertises them
alongside the LAN route. Specific-interface `custom` and `tailnet` binds do not
receive that fallback because a loopback Serve proxy cannot reach those
listeners. The iOS app probes the advertised routes in order and saves the first
reachable endpoint.
### Approve a node device
```bash

View File

@@ -36,7 +36,7 @@ openclaw devices approve <requestId>
- `--password <password>`: override the gateway password the bootstrap flow authenticates against
- `--setup-code-only`: print only the setup code
- `--no-ascii`: skip ASCII QR rendering
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`)
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, optional `gatewayUrls`, `auth`, `urlSource`)
`--token` and `--password` are mutually exclusive.
@@ -53,6 +53,8 @@ Pairing-mutation scopes and `operator.admin` still require a separate approved o
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://`.
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. Specific-interface `custom` and `tailnet` binds do not receive that fallback because a loopback Serve proxy cannot reach those listeners. Current iOS clients probe the advertised routes in order and save the first reachable one; the legacy `url` field remains unchanged for older clients.
With `--remote`, one of `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel` is required.
## Auth resolution (no `--remote`)

View File

@@ -43,6 +43,9 @@ creation has a token or password auth path.
3. In the iOS app, open **Settings** -> **Gateway**, scan the QR code (or paste
the setup code), and connect.
If the setup code contains both LAN and Tailscale Serve routes, the app
probes them in order and saves the first reachable endpoint.
4. The official app connects automatically. If **Devices** shows a pending
request, review its role and scopes before approving it.

View File

@@ -13,6 +13,7 @@ export {
resolveGatewayBindUrl,
resolveGatewayPort,
resolveTailnetHostWithRunner,
resolveTailscaleServeGatewayUrlsWithRunner,
} from "openclaw/plugin-sdk/core";
export { resolveAdvertisedLanHost } from "openclaw/plugin-sdk/gateway-runtime";
export {

View File

@@ -19,6 +19,7 @@ const pluginApiMocks = vi.hoisted(() => ({
revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })),
renderQrPngDataUrl: vi.fn(async () => "data:image/png;base64,ZmFrZXBuZw=="),
resolveGatewayPort: vi.fn(() => 18789),
resolveTailscaleServeGatewayUrlsWithRunner: vi.fn(async () => []),
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
writeQrPngTempFile: vi.fn(async (dataValue: string, opts: { tmpRoot: string }) => {
const dirPath = await fs.mkdtemp(path.join(opts.tmpRoot, "device-pair-qr-"));
@@ -46,6 +47,8 @@ vi.mock("./api.js", () => {
resolveGatewayBindUrl: vi.fn(),
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
resolveTailnetHostWithRunner: vi.fn(),
resolveTailscaleServeGatewayUrlsWithRunner:
pluginApiMocks.resolveTailscaleServeGatewayUrlsWithRunner,
runPluginCommandWithTimeout: vi.fn(),
writeQrPngTempFile: pluginApiMocks.writeQrPngTempFile,
};
@@ -63,6 +66,7 @@ import {
resolveAdvertisedLanHost,
resolveGatewayBindUrl,
resolveTailnetHostWithRunner,
resolveTailscaleServeGatewayUrlsWithRunner,
} from "./api.js";
import registerDevicePair from "./index.js";
@@ -896,6 +900,68 @@ describe("device-pair /pair default setup code", () => {
expect(requireText(result)).toContain("Gateway: ws://10.211.55.3:18789");
});
it("includes a Tailscale Serve fallback for LAN bind-derived setup urls", async () => {
vi.mocked(resolveAdvertisedLanHost).mockResolvedValueOnce("192.168.139.3");
vi.mocked(resolveGatewayBindUrl).mockImplementationOnce((params) => ({
url: `ws://${params.pickLanHost()}:18789`,
source: "gateway.bind=lan",
}));
vi.mocked(resolveTailscaleServeGatewayUrlsWithRunner).mockResolvedValueOnce([
"wss://clawmac.tail.ts.net:8443",
]);
const command = registerPairCommand({
config: {
gateway: {
bind: "lan",
auth: { mode: "token", token: "gateway-token" },
},
},
pluginConfig: { publicUrl: undefined },
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
}),
);
expect(requireText(result)).toContain("Gateway: ws://192.168.139.3:18789");
expect(requireText(result)).toContain("Fallback: wss://clawmac.tail.ts.net:8443");
});
it("does not advertise a loopback Serve route for a custom bind", async () => {
vi.mocked(resolveGatewayBindUrl).mockReturnValueOnce({
url: "ws://192.168.139.3:18789",
source: "gateway.bind=custom",
});
const command = registerPairCommand({
config: {
gateway: {
bind: "custom",
customBindHost: "192.168.139.3",
auth: { mode: "token", token: "gateway-token" },
},
},
pluginConfig: { publicUrl: undefined },
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
}),
);
expect(resolveTailscaleServeGatewayUrlsWithRunner).not.toHaveBeenCalled();
expect(requireText(result)).toContain("Gateway: ws://192.168.139.3:18789");
expect(requireText(result)).not.toContain("Fallback:");
});
it("rejects public cleartext setup urls before issuing setup codes", async () => {
const command = registerPairCommand({
pluginConfig: {

View File

@@ -33,12 +33,14 @@ type DevicePairPluginConfig = {
type SetupPayload = {
url: string;
urls?: string[];
bootstrapToken: string;
expiresAtMs: number;
};
type ResolveUrlResult = {
url?: string;
urls?: string[];
source?: string;
error?: string;
};
@@ -418,6 +420,18 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu
pickTailnetHost: pickTailnetIPv4,
pickLanHost: () => advertisedLanHost,
});
if (bindResult && "url" in bindResult && bindResult.source === "gateway.bind=lan") {
const { resolveTailscaleServeGatewayUrlsWithRunner, runPluginCommandWithTimeout } =
await loadDevicePairApiModule();
const serveUrls = await resolveTailscaleServeGatewayUrlsWithRunner(port, (argv, opts) =>
runPluginCommandWithTimeout({ argv, timeoutMs: opts.timeoutMs }),
);
const urls = [...new Set([bindResult.url, ...serveUrls])].slice(0, 8);
return {
...bindResult,
...(urls.length > 1 ? { urls } : {}),
};
}
if (bindResult) {
return bindResult;
}
@@ -437,7 +451,13 @@ async function resolveMobilePairingGatewayUrl(api: OpenClawPluginApi): Promise<R
if (mobilePairingUrlError) {
return { error: mobilePairingUrlError };
}
return result;
const urls = result.urls?.filter(
(url) => !validateMobilePairingUrl(url, "tailscale serve status"),
);
return {
...result,
...(urls && urls.length > 1 ? { urls } : {}),
};
}
function encodeSetupCode(payload: SetupPayload): string {
@@ -494,7 +514,7 @@ function formatSetupReply(payload: SetupPayload, authLabel: string): string {
"Setup code:",
setupCode,
"",
`Gateway: ${payload.url}`,
...formatGatewayLines(payload),
`Auth: ${authLabel}`,
...buildSecurityNoticeLines({
kind: "setup code",
@@ -523,7 +543,7 @@ function buildQrInfoLines(params: {
expiresAtMs: number;
}): string[] {
return [
`Gateway: ${params.payload.url}`,
...formatGatewayLines(params.payload),
`Auth: ${params.authLabel}`,
...buildSecurityNoticeLines({
kind: "QR code",
@@ -543,7 +563,7 @@ function formatQrInfoMarkdown(params: {
expiresAtMs: number;
}): string {
return [
`- Gateway: ${params.payload.url}`,
...formatGatewayLines(params.payload).map((line) => `- ${line}`),
`- Auth: ${params.authLabel}`,
...buildSecurityNoticeLines({
kind: "QR code",
@@ -578,7 +598,13 @@ function resolveQrReplyTarget(ctx: QrCommandContext): string {
);
}
async function issueSetupPayload(url: string): Promise<SetupPayload> {
function formatGatewayLines(payload: SetupPayload): string[] {
return (payload.urls ?? [payload.url]).map((url, index) =>
index === 0 ? `Gateway: ${url}` : `Fallback: ${url}`,
);
}
async function issueSetupPayload(url: string, urls?: string[]): Promise<SetupPayload> {
const { issueDeviceBootstrapToken, PAIRING_SETUP_BOOTSTRAP_PROFILE } =
await loadDevicePairApiModule();
const issuedBootstrap = await issueDeviceBootstrapToken({
@@ -586,6 +612,7 @@ async function issueSetupPayload(url: string): Promise<SetupPayload> {
});
return {
url,
...(urls ? { urls } : {}),
bootstrapToken: issuedBootstrap.token,
expiresAtMs: issuedBootstrap.expiresAtMs,
};
@@ -757,7 +784,7 @@ export default definePluginEntry({
}
}
let payload = await issueSetupPayload(urlResult.url);
let payload = await issueSetupPayload(urlResult.url, urlResult.urls);
let setupCode = encodeSetupCode(payload);
const infoLines = buildQrInfoLines({
@@ -802,7 +829,7 @@ 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);
payload = await issueSetupPayload(urlResult.url, urlResult.urls);
setupCode = encodeSetupCode(payload);
} finally {
if (qrFilePath) {
@@ -824,7 +851,7 @@ 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);
payload = await issueSetupPayload(urlResult.url, urlResult.urls);
return {
text:
"QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.\n\n" +
@@ -862,7 +889,7 @@ export default definePluginEntry({
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
"";
const payload = await issueSetupPayload(urlResult.url);
const payload = await issueSetupPayload(urlResult.url, urlResult.urls);
if (channel === "telegram" && target) {
try {

View File

@@ -112,6 +112,9 @@ export const DevicePairSetupCodeResultSchema = Type.Object(
setupCode: NonEmptyString,
qrDataUrl: Type.Optional(SetupCodeQrDataUrlSchema),
gatewayUrl: NonEmptyString,
gatewayUrls: Type.Optional(
Type.Array(NonEmptyString, { minItems: 2, maxItems: 8, uniqueItems: true }),
),
auth: Type.Union([Type.Literal("token"), Type.Literal("password")]),
urlSource: NonEmptyString,
},

View File

@@ -192,12 +192,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10429,
10430,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
5206,
5207,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -86,6 +86,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
["CronListParams", ["compact"]],
["CronRunLogEntry", ["errorReason", "failureNotificationDelivery"]],
["ExecApprovalRequestParams", ["requireDeliveryRoute", "suppressDelivery"]],
["DevicePairSetupCodeResult", ["gatewayUrls"]],
["AgentSummary", ["thinkingLevels", "thinkingOptions", "thinkingDefault"]],
["ModelChoice", ["available"]],
];

View File

@@ -220,6 +220,7 @@ export function registerQrCli(program: Command) {
defaultRuntime.writeJson({
setupCode,
gatewayUrl: resolved.payload.url,
...(resolved.payload.urls ? { gatewayUrls: resolved.payload.urls } : {}),
auth: resolved.authLabel,
urlSource: resolved.urlSource,
});
@@ -240,6 +241,8 @@ export function registerQrCli(program: Command) {
lines.push(
`${theme.muted("Setup code:")} ${setupCode}`,
`${theme.muted("Gateway:")} ${resolved.payload.url}`,
...(resolved.payload.urls?.slice(1).map((url) => `${theme.muted("Fallback:")} ${url}`) ??
[]),
`${theme.muted("Auth:")} ${resolved.authLabel}`,
`${theme.muted("Source:")} ${resolved.urlSource}`,
"",

View File

@@ -48,7 +48,11 @@ function createOptions(
const okResolution = {
ok: true as const,
payload: { url: "wss://gw.example:8443", bootstrapToken: "boot-123" },
payload: {
url: "wss://gw.example:8443",
urls: ["wss://gw.example:8443", "ws://192.168.1.20:18789"],
bootstrapToken: "boot-123",
},
authLabel: "token" as const,
urlSource: "remote",
};
@@ -77,6 +81,7 @@ describe("device.pair.setupCode", () => {
setupCode: "SETUP-CODE-XYZ",
qrDataUrl: "data:image/png;base64,qr",
gatewayUrl: "wss://gw.example:8443",
gatewayUrls: ["wss://gw.example:8443", "ws://192.168.1.20:18789"],
auth: "token",
urlSource: "remote",
});

View File

@@ -72,6 +72,7 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
setupCode,
...(qrDataUrl ? { qrDataUrl } : {}),
gatewayUrl: resolved.payload.url,
...(resolved.payload.urls ? { gatewayUrls: resolved.payload.urls } : {}),
// Label only — never the raw gateway token/password.
auth: resolved.authLabel,
urlSource: requestPublicUrl ? "request.publicUrl" : resolved.urlSource,

View File

@@ -111,6 +111,7 @@ describe("pairing setup code", () => {
params: {
authLabel: string;
url?: string;
urls?: string[];
urlSource?: string;
},
) {
@@ -130,6 +131,9 @@ describe("pairing setup code", () => {
if (params.url) {
expect(resolved.payload.url).toBe(params.url);
}
if (params.urls) {
expect(resolved.payload.urls).toEqual(params.urls);
}
if (params.urlSource) {
expect(resolved.urlSource).toBe(params.urlSource);
}
@@ -149,6 +153,7 @@ describe("pairing setup code", () => {
expected: {
authLabel: string;
url: string;
urls?: string[];
urlSource: string;
};
runCommandWithTimeout?: ReturnType<typeof vi.fn>;
@@ -591,7 +596,7 @@ describe("pairing setup code", () => {
urlSource: "gateway.bind=lan",
},
runCommandWithTimeout,
expectedRunCommandCalls: 1,
expectedRunCommandCalls: 3,
});
});
@@ -636,7 +641,73 @@ describe("pairing setup code", () => {
urlSource: "gateway.bind=lan",
},
runCommandWithTimeout,
expectedRunCommandCalls: 1,
expectedRunCommandCalls: 3,
});
});
it("adds a configured Tailscale Serve route to a LAN setup code", async () => {
const defaultRoute = createDefaultRouteRunner("en0");
const runCommandWithTimeout = vi.fn(async (argv: string[]) => {
if (argv.includes("serve")) {
return {
code: 0,
stdout: JSON.stringify({
TCP: { "8443": { HTTPS: true } },
Web: {
"clawmac.tail.ts.net:8443": {
Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } },
},
},
}),
stderr: "",
};
}
return defaultRoute();
});
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
options: {
networkInterfaces: () => createIpv4NetworkInterfaces("192.168.139.3"),
runCommandWithTimeout,
} satisfies ResolveSetupOptions,
expected: {
authLabel: "token",
url: "ws://192.168.139.3:18789",
urls: ["ws://192.168.139.3:18789", "wss://clawmac.tail.ts.net:8443"],
urlSource: "gateway.bind=lan",
},
runCommandWithTimeout,
expectedRunCommandCalls: 2,
});
});
it("does not advertise a loopback Serve route for a custom bind", async () => {
const runCommandWithTimeout = vi.fn(async () => {
throw new Error("Tailscale Serve discovery must not run for a custom bind");
});
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "custom",
customBindHost: "192.168.139.3",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
options: { runCommandWithTimeout } satisfies ResolveSetupOptions,
expected: {
authLabel: "token",
url: "ws://192.168.139.3:18789",
urlSource: "gateway.bind=custom",
},
runCommandWithTimeout,
expectedRunCommandCalls: 0,
});
});

View File

@@ -27,14 +27,18 @@ import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-prof
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
import {
resolveTailnetHostWithRunner,
resolveTailscaleServeGatewayUrlsWithRunner,
resolveTailscalePublishedHost,
} from "../shared/tailscale-status.js";
export type PairingSetupPayload = {
url: string;
urls?: string[];
bootstrapToken: string;
};
const PAIRING_SETUP_MAX_URLS = 8;
export type PairingSetupCommandResult = {
code: number | null;
stdout: string;
@@ -409,10 +413,25 @@ export async function resolvePairingSetupFromConfig(
return { ok: false, error: "Gateway auth is not configured (no token or password)." };
}
const urls = [urlResult.url];
if (urlResult.source === "gateway.bind=lan") {
const serveUrls = await resolveTailscaleServeGatewayUrlsWithRunner(
resolveGatewayPort(cfgForAuth, env),
options.runCommandWithTimeout,
);
for (const serveUrl of serveUrls) {
if (!validateMobilePairingUrl(serveUrl, "tailscale serve status")) {
urls.push(serveUrl);
}
}
}
const uniqueUrls = [...new Set(urls)].slice(0, PAIRING_SETUP_MAX_URLS);
return {
ok: true,
payload: {
url: urlResult.url,
...(uniqueUrls.length > 1 ? { urls: uniqueUrls } : {}),
bootstrapToken: (
await issueDeviceBootstrapToken({
baseDir: options.pairingBaseDir,

View File

@@ -285,7 +285,10 @@ export async function ensureConfiguredAcpBindingReady(params: {
return runtime.ensureConfiguredAcpBindingReady(params);
}
export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
export {
resolveTailnetHostWithRunner,
resolveTailscaleServeGatewayUrlsWithRunner,
} from "../shared/tailscale-status.js";
export type {
TailscaleStatusCommandResult,
TailscaleStatusCommandRunner,

View File

@@ -1,6 +1,9 @@
// Tailscale status tests cover status parsing and validation.
import { describe, expect, it, vi } from "vitest";
import { resolveTailnetHostWithRunner } from "./tailscale-status.js";
import {
resolveTailnetHostWithRunner,
resolveTailscaleServeGatewayUrlsWithRunner,
} from "./tailscale-status.js";
describe("shared/tailscale-status", () => {
it("returns null when no runner is provided", async () => {
@@ -83,4 +86,77 @@ describe("shared/tailscale-status", () => {
});
await expect(resolveTailnetHostWithRunner(invalid)).resolves.toBeNull();
});
it("finds persistent HTTPS Serve routes that proxy the gateway root", async () => {
const run = vi.fn().mockResolvedValue({
code: 0,
stdout: JSON.stringify({
TCP: { "443": { HTTPS: true }, "8443": { HTTPS: true } },
Web: {
"mac.tail.ts.net:443": {
Handlers: { "/": { Proxy: "http://127.0.0.1:8096" } },
},
"mac.tail.ts.net:8443": {
Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } },
},
},
}),
});
await expect(resolveTailscaleServeGatewayUrlsWithRunner(18789, run)).resolves.toEqual([
"wss://mac.tail.ts.net:8443",
]);
expect(run).toHaveBeenCalledWith(["tailscale", "serve", "status", "--json"], {
timeoutMs: 5000,
});
});
it("ignores non-root, non-HTTPS, and non-loopback Serve handlers", async () => {
const run = vi.fn().mockResolvedValue({
code: 0,
stdout: JSON.stringify({
TCP: { "80": { HTTP: true }, "443": { HTTPS: true } },
Web: {
"mac.tail.ts.net:80": {
Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } },
},
"mac.tail.ts.net:443": {
Handlers: { "/openclaw": { Proxy: "http://127.0.0.1:18789" } },
},
"other.tail.ts.net:443": {
Handlers: { "/": { Proxy: "http://192.168.1.20:18789" } },
},
},
}),
});
await expect(resolveTailscaleServeGatewayUrlsWithRunner(18789, run)).resolves.toEqual([]);
});
it("ignores load-balanced Tailscale Services and public Funnel routes", async () => {
const run = vi.fn().mockResolvedValue({
code: 0,
stdout: JSON.stringify({
TCP: { "443": { HTTPS: true } },
Web: {
"mac.tail.ts.net:443": {
Handlers: { "/": { Proxy: "127.0.0.1:18789" } },
},
},
AllowFunnel: { "mac.tail.ts.net:443": true },
Services: {
"svc:openclaw": {
TCP: { "443": { HTTPS: true } },
Web: {
"openclaw.tail.ts.net:443": {
Handlers: { "/": { Proxy: "127.0.0.1:18789" } },
},
},
},
},
}),
});
await expect(resolveTailscaleServeGatewayUrlsWithRunner(18789, run)).resolves.toEqual([]);
});
});

View File

@@ -26,6 +26,28 @@ const TailscaleStatusSchema = z.object({
.optional(),
});
const TailscaleServeTcpHandlerSchema = z.object({
HTTPS: z.boolean().optional(),
});
const TailscaleServeWebServerSchema = z.object({
Handlers: z.record(
z.string(),
z.object({
Proxy: z.string().optional(),
}),
),
});
const TailscaleServeServiceSchema = z.object({
TCP: z.record(z.string(), TailscaleServeTcpHandlerSchema).optional(),
Web: z.record(z.string(), TailscaleServeWebServerSchema).optional(),
});
const TailscaleServeConfigSchema = TailscaleServeServiceSchema.extend({
AllowFunnel: z.record(z.string(), z.boolean()).optional(),
});
function parsePossiblyNoisyStatus(raw: string): z.infer<typeof TailscaleStatusSchema> | null {
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
@@ -45,6 +67,71 @@ function extractTailnetHostFromStatusJson(raw: string): string | null {
return ips.length > 0 ? (ips[0] ?? null) : null;
}
function parseLoopbackProxyPort(proxy: string): number | null {
const trimmed = proxy.trim();
if (/^\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10);
}
const normalized = trimmed.includes("://") ? trimmed : `http://${trimmed}`;
try {
const parsed = new URL(normalized);
const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (!(host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host))) {
return null;
}
const port = Number.parseInt(parsed.port, 10);
return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : null;
} catch {
return null;
}
}
function collectServeGatewayUrls(
config: z.infer<typeof TailscaleServeServiceSchema>,
gatewayPort: number,
allowFunnel: Record<string, boolean>,
): string[] {
const urls: string[] = [];
for (const [hostPort, webServer] of Object.entries(config.Web ?? {})) {
const handler = webServer.Handlers["/"];
if (
allowFunnel[hostPort] ||
!handler?.Proxy ||
parseLoopbackProxyPort(handler.Proxy) !== gatewayPort
) {
continue;
}
try {
const endpoint = new URL(`https://${hostPort}`);
const port = endpoint.port || "443";
if (config.TCP?.[port]?.HTTPS !== true) {
continue;
}
urls.push(`wss://${endpoint.host}`);
} catch {
continue;
}
}
return urls;
}
function extractServeGatewayUrls(raw: string, gatewayPort: number): string[] {
const start = raw.indexOf("{");
const end = raw.lastIndexOf("}");
if (start === -1 || end <= start) {
return [];
}
const parsed = safeParseJsonWithSchema(TailscaleServeConfigSchema, raw.slice(start, end + 1));
if (!parsed) {
return [];
}
// Service entries can load-balance to another node, while Funnel routes are public.
// Pairing fallbacks must stay pinned to this node and available only inside the tailnet.
return [
...new Set(collectServeGatewayUrls(parsed, gatewayPort, parsed.AllowFunnel ?? {})),
].toSorted();
}
/** Resolves the host published to clients for tailnet or Tailscale Serve gateway modes. */
export function resolveTailscalePublishedHost(params: {
tailscaleMode: string;
@@ -98,3 +185,30 @@ export async function resolveTailnetHostWithRunner(
}
return null;
}
/** Finds persistent HTTPS Serve routes whose root proxy targets this gateway port. */
export async function resolveTailscaleServeGatewayUrlsWithRunner(
gatewayPort: number,
runCommandWithTimeout?: TailscaleStatusCommandRunner,
): Promise<string[]> {
if (!runCommandWithTimeout) {
return [];
}
for (const candidate of TAILSCALE_STATUS_COMMAND_CANDIDATES) {
try {
const result = await runCommandWithTimeout([candidate, "serve", "status", "--json"], {
timeoutMs: 5000,
});
if (result.code !== 0 || !result.stdout.trim()) {
continue;
}
const urls = extractServeGatewayUrls(result.stdout, gatewayPort);
if (urls.length > 0) {
return urls;
}
} catch {
continue;
}
}
return [];
}

View File

@@ -28,6 +28,7 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
const description = t("nodes.pairing.subtitle");
const setup = props.setup;
const pendingCount = props.pendingCount;
const gatewayUrls = setup?.gatewayUrls ?? (setup ? [setup.gatewayUrl] : []);
return html`
<openclaw-modal-dialog label=${title} description=${description} @modal-cancel=${props.onClose}>
@@ -90,9 +91,15 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
<div class="device-pair-setup__meta">
<span class="pill">${setup.auth}</span>
<span class="device-pair-setup__gateway" title=${setup.gatewayUrl}
>${setup.gatewayUrl}</span
>
<div class="device-pair-setup__gateways">
${gatewayUrls.map(
(gatewayUrl) => html`
<span class="device-pair-setup__gateway" title=${gatewayUrl}
>${gatewayUrl}</span
>
`,
)}
</div>
</div>
<div class="device-pair-setup__actions">

View File

@@ -6415,6 +6415,13 @@ details[open] > .ov-expandable-toggle::after {
white-space: nowrap;
}
.device-pair-setup__gateways {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.device-pair-setup__actions {
display: flex;
flex-wrap: wrap;