diff --git a/CHANGELOG.md b/CHANGELOG.md index d85b4908cf1..324c5779f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Release/beta smoke: resolve the dispatched Telegram beta E2E run from `gh run list` when `gh workflow run` returns no run URL, so the maintainer helper does not fail immediately after dispatch. Thanks @vincentkoc. - Media/images: keep HEIC/HEIF attachments fail-closed when optional Sharp conversion is unavailable instead of sending originals that still need conversion. Thanks @vincentkoc. - Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting. +- iOS/mobile pairing: reject non-loopback `ws://` setup URLs before QR/setup-code issuance and let the iOS Gateway settings screen scan QR codes or paste full setup-code messages. Thanks @BunsDev. - Telegram/streaming: sanitize tool-progress draft preview backticks before shared compaction, so long backtick-heavy progress text still renders inside the safe code-formatted preview instead of collapsing to an ellipsis. - UI/chat: remove the unsupported `line-clamp` declaration from the chat queue text rule to eliminate Firefox console noise without changing visible truncation behavior. Thanks @ZanderH-code. - Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc. diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md index d02629a6613..2c2859c3315 100644 --- a/apps/ios/CHANGELOG.md +++ b/apps/ios/CHANGELOG.md @@ -1,5 +1,11 @@ # OpenClaw iOS Changelog +## 2026.5.4 - 2026-05-04 + +Maintenance update for the current OpenClaw development release. + +- Gateway pairing now supports scanning QR codes from Settings and accepts full copied setup-code messages while keeping non-loopback `ws://` setup links blocked. + ## 2026.5.3 - 2026-05-03 Maintenance update for the current OpenClaw development release. diff --git a/apps/ios/README.md b/apps/ios/README.md index ae6fe39b7e1..7bf735c17f3 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -241,7 +241,7 @@ gateway can only send pushes for iOS devices that paired with that gateway. ## What Works Now (Concrete) -- Pairing via setup code flow (`/pair` then `/pair approve` in Telegram). +- Pairing via QR or setup code flow (`/pair qr` or `/pair`, then `/pair approve` in Telegram). - Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt. - Chat + Talk surfaces through the operator gateway session. - iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications. diff --git a/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/apps/ios/Sources/Gateway/GatewaySetupCode.swift deleted file mode 100644 index d52ca023563..00000000000 --- a/apps/ios/Sources/Gateway/GatewaySetupCode.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation - -struct GatewaySetupPayload: Codable { - var url: String? - var host: String? - var port: Int? - var tls: Bool? - var bootstrapToken: String? - var token: String? - var password: String? -} - -enum GatewaySetupCode { - static func decode(raw: String) -> GatewaySetupPayload? { - if let payload = decodeFromJSON(raw) { - return payload - } - if let decoded = decodeBase64Payload(raw), - let payload = decodeFromJSON(decoded) - { - return payload - } - return nil - } - - private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? { - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data) - } - - private static func decodeBase64Payload(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = normalized.count % 4 - let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding) - guard let data = Data(base64Encoded: padded) else { return nil } - return String(data: data, encoding: .utf8) - } -} diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift index a6c440781d7..3ae10c70417 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -248,38 +248,23 @@ private struct ManualEntryStep: View { return } - guard let payload = GatewaySetupCode.decode(raw: raw) else { - self.setupStatusText = "Setup code not recognized." + guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else { + self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." return } - if let urlString = payload.url, let url = URL(string: urlString) { - self.applyURL(url) - } else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.manualHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - if let port = payload.port { - self.manualPortText = String(port) - } else { - self.manualPortText = "" - } - if let tls = payload.tls { - self.manualUseTLS = tls - } - } else if let url = URL(string: raw), url.scheme != nil { - self.applyURL(url) - } else { - self.setupStatusText = "Setup code missing URL or host." - return - } + self.manualHost = link.host + self.manualPortText = String(link.port) + self.manualUseTLS = link.tls - if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let token = link.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines) - } else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + } else if link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { self.manualToken = "" } - if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let password = link.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) - } else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + } else if link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { self.manualPassword = "" } @@ -287,30 +272,12 @@ private struct ManualEntryStep: View { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmedInstanceId.isEmpty { let trimmedBootstrapToken = - payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId) } self.setupStatusText = "Setup code applied." } - - private func applyURL(_ url: URL) { - guard let host = url.host, !host.isEmpty else { return } - self.manualHost = host - if let port = url.port { - self.manualPortText = String(port) - } else { - self.manualPortText = "" - } - let scheme = (url.scheme ?? "").lowercased() - if scheme == "wss" || scheme == "https" { - self.manualUseTLS = true - } else if scheme == "ws" || scheme == "http" { - self.manualUseTLS = false - } - } - - // (GatewaySetupCode) decode raw setup codes. } @MainActor diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 5bfc83c929d..3f0f3d09f0b 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -203,14 +203,7 @@ struct OnboardingWizardView: View { return } if let message = self.detectQRCode(from: data) { - if let link = GatewayConnectDeepLink.fromSetupCode(message) { - self.handleScannedLink(link) - return - } - if let url = URL(string: message), - let route = DeepLinkParser.parse(url), - case let .gateway(link) = route - { + if let link = GatewayConnectDeepLink.fromSetupInput(message) { self.handleScannedLink(link) return } diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift index d326c09c42b..97cc22b8e3c 100644 --- a/apps/ios/Sources/Onboarding/QRScannerView.swift +++ b/apps/ios/Sources/Onboarding/QRScannerView.swift @@ -65,20 +65,11 @@ struct QRScannerView: UIViewControllerRepresentable { let payload = barcode.payloadStringValue else { continue } - // Try setup code format first (base64url JSON from /pair qr). - if let link = GatewayConnectDeepLink.fromSetupCode(payload) { + if let link = GatewayConnectDeepLink.fromSetupInput(payload) { self.handled = true - self.parent.onGatewayLink(link) - return - } - - // Fall back to deep link URL format (openclaw://gateway?...). - if let url = URL(string: payload), - let route = DeepLinkParser.parse(url), - case let .gateway(link) = route - { - self.handled = true - self.parent.onGatewayLink(link) + Task { @MainActor in + self.parent.onGatewayLink(link) + } return } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 839a22da705..38057784870 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -49,6 +49,8 @@ struct SettingsTab: View { @State private var defaultShareInstruction: String = "" @AppStorage("gateway.setupCode") private var setupCode: String = "" @State private var setupStatusText: String? + @State private var showQRScanner: Bool = false + @State private var scannerError: String? @State private var manualGatewayPortText: String = "" @State private var gatewayExpanded: Bool = true @State private var selectedAgentPickerId: String = "" @@ -98,6 +100,13 @@ struct SettingsTab: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() + Button { + self.openGatewayQRScanner() + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + } + .disabled(self.connectingGatewayID != nil) + Button { Task { await self.applySetupCodeAndConnect() } } label: { @@ -430,6 +439,30 @@ struct SettingsTab: View { }) } } + .sheet(isPresented: self.$showQRScanner) { + NavigationStack { + QRScannerView( + onGatewayLink: { link in + self.handleScannedGatewayLink(link) + }, + onError: { error in + self.showQRScanner = false + self.setupStatusText = "Scanner error: \(error)" + self.scannerError = error + }, + onDismiss: { + self.showQRScanner = false + }) + .ignoresSafeArea() + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { self.showQRScanner = false } + } + } + } + } .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) { Button("Reset", role: .destructive) { self.resetOnboarding() @@ -446,6 +479,14 @@ struct SettingsTab: View { message: Text(help.message), dismissButton: .default(Text("OK"))) } + .alert("QR Scanner Unavailable", isPresented: Binding( + get: { self.scannerError != nil }, + set: { if !$0 { self.scannerError = nil } })) + { + Button("OK", role: .cancel) {} + } message: { + Text(self.scannerError ?? "") + } .onAppear { self.lastLocationModeRaw = self.locationEnabledModeRaw self.syncManualPortText() @@ -769,39 +810,28 @@ struct SettingsTab: View { return false } - guard let payload = GatewaySetupCode.decode(raw: raw) else { - self.setupStatusText = "Setup code not recognized." + guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else { + self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." return false } - if let urlString = payload.url, let url = URL(string: urlString) { - self.applySetupURL(url) - } else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.manualGatewayHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - if let port = payload.port { - self.manualGatewayPort = port - self.manualGatewayPortText = String(port) - } else { - self.manualGatewayPort = 0 - self.manualGatewayPortText = "" - } - if let tls = payload.tls { - self.manualGatewayTLS = tls - } - } else if let url = URL(string: raw), url.scheme != nil { - self.applySetupURL(url) - } else { - self.setupStatusText = "Setup code missing URL or host." - return false - } + self.applyGatewayLink(link) + return true + } + + private func applyGatewayLink(_ link: GatewayConnectDeepLink) { + self.manualGatewayHost = link.host + self.manualGatewayPort = link.port + self.manualGatewayPortText = String(link.port) + self.manualGatewayTLS = link.tls let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedBootstrapToken = - payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmedInstanceId.isEmpty { GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId) } - if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let token = link.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) self.gatewayToken = trimmedToken if !trimmedInstanceId.isEmpty { @@ -813,7 +843,7 @@ struct SettingsTab: View { GatewaySettingsStore.saveGatewayToken("", instanceId: trimmedInstanceId) } } - if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let password = link.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) self.gatewayPassword = trimmedPassword if !trimmedInstanceId.isEmpty { @@ -825,26 +855,33 @@ struct SettingsTab: View { GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId) } } - - return true } - private func applySetupURL(_ url: URL) { - guard let host = url.host, !host.isEmpty else { return } - self.manualGatewayHost = host - if let port = url.port { - self.manualGatewayPort = port - self.manualGatewayPortText = String(port) - } else { - self.manualGatewayPort = 0 - self.manualGatewayPortText = "" - } - let scheme = (url.scheme ?? "").lowercased() - if scheme == "wss" || scheme == "https" { - self.manualGatewayTLS = true - } else if scheme == "ws" || scheme == "http" { - self.manualGatewayTLS = false + private func openGatewayQRScanner() { + self.appModel.disconnectGateway() + self.connectingGatewayID = nil + self.setupStatusText = "Opening QR scanner…" + self.showQRScanner = true + } + + private func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) { + self.showQRScanner = false + self.setupCode = "" + self.applyGatewayLink(link) + self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)…" + Task { await self.connectAfterScannedGatewayLink() } + } + + private func connectAfterScannedGatewayLink() async { + let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedPort = self.resolvedManualPort(host: host) + guard let port = resolvedPort else { + self.setupStatusText = "Failed: invalid port" + return } + let ok = await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) + guard ok else { return } + await self.connectManual() } private func resolvedManualPort(host: String) -> Int? { @@ -892,8 +929,6 @@ struct SettingsTab: View { queueLabel: "gateway.preflight") } - // (GatewaySetupCode) decode raw setup codes. - private func connectManual() async { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty else { diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index a462704dfba..99eda1c355e 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -21,7 +21,6 @@ Sources/Gateway/GatewayProblemView.swift Sources/Gateway/GatewayQuickSetupSheet.swift Sources/Gateway/GatewayServiceResolver.swift Sources/Gateway/GatewaySettingsStore.swift -Sources/Gateway/GatewaySetupCode.swift Sources/Gateway/GatewayTrustPromptAlert.swift Sources/Gateway/KeychainStore.swift Sources/Gateway/TCPProbe.swift diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index bac3288add1..d76bc8c84ee 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -161,4 +161,34 @@ private func agentAction( token: nil, password: nil)) } + + @Test func parseGatewaySetupInputParsesFullCopiedSetupMessage() { + let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"# + let link = GatewayConnectDeepLink.fromSetupInput(""" + Pairing setup code generated. + + Setup code: + \(setupCode(from: payload)) + """) + + #expect(link == .init( + host: "gateway.example.com", + port: 443, + tls: true, + bootstrapToken: "tok", + token: nil, + password: nil)) + } + + @Test func parseGatewaySetupInputParsesRawGatewayURL() { + let link = GatewayConnectDeepLink.fromSetupInput("wss://gateway.example.com:444") + + #expect(link == .init( + host: "gateway.example.com", + port: 444, + tls: true, + bootstrapToken: nil, + token: nil, + password: nil)) + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 7d7e099e6fe..22c099ad8a5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -6,6 +6,16 @@ public enum DeepLinkRoute: Sendable, Equatable { } public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { + private struct SetupPayload: Decodable { + let url: String? + let host: String? + let port: Int? + let tls: Bool? + let bootstrapToken: String? + let token: String? + let password: String? + } + public let host: String public let port: Int public let tls: Bool @@ -27,28 +37,118 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { return URL(string: "\(scheme)://\(self.host):\(self.port)") } - /// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`). + /// Parse a gateway setup input from the QR/scanner/manual entry surfaces. + /// + /// Accepted inputs are: + /// - device-pair setup code (base64url-encoded JSON) + /// - raw setup JSON + /// - a copied message containing a `Setup code:` line + /// - an `openclaw://gateway?...` deep link + /// - a raw `ws://` or `wss://` gateway URL + public static func fromSetupInput(_ input: String) -> GatewayConnectDeepLink? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let link = fromSetupCode(trimmed) { + return link + } + if let url = URL(string: trimmed), + let route = DeepLinkParser.parse(url), + case let .gateway(link) = route + { + return link + } + return fromGatewayURLString( + trimmed, + bootstrapToken: nil, + token: nil, + password: nil) + } + + /// Parse a gateway setup payload from a device-pair setup code or copied setup text. + /// + /// Accepted inputs are: + /// - base64url-encoded setup JSON + /// - raw setup JSON + /// - copied text/message content containing one or more extractable setup-code candidates + /// + /// Accepted payload shapes are: + /// - `{url, 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. public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? { - guard let data = decodeBase64Url(code) else { return nil } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - guard let urlString = json["url"] as? String, - let parsed = URLComponents(string: urlString), + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let link = decodeSetupPayload(from: Data(trimmed.utf8)) { + return link + } + if let data = decodeBase64Url(trimmed), + let link = decodeSetupPayload(from: data) + { + return link + } + for candidate in setupCodeCandidates(in: trimmed) where candidate != trimmed { + if let data = decodeBase64Url(candidate), + let link = decodeSetupPayload(from: data) + { + return link + } + } + return nil + } + + 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 + { + return fromGatewayURLString( + urlString, + bootstrapToken: payload.bootstrapToken, + token: payload.token, + password: payload.password) + } + guard let host = payload.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty + else { + return nil + } + let tls = payload.tls ?? true + if !tls, !LoopbackHost.isLoopbackHost(host) { + return nil + } + return GatewayConnectDeepLink( + host: host, + port: payload.port ?? (tls ? 443 : 18789), + tls: tls, + bootstrapToken: payload.bootstrapToken, + token: payload.token, + password: payload.password) + } + + private static func fromGatewayURLString( + _ urlString: String, + bootstrapToken: String?, + token: String?, + password: String?) -> GatewayConnectDeepLink? + { + guard let parsed = URLComponents(string: urlString), let hostname = parsed.host, !hostname.isEmpty else { return nil } let scheme = (parsed.scheme ?? "ws").lowercased() - guard scheme == "ws" || scheme == "wss" else { return nil } - let tls = scheme == "wss" + guard scheme == "ws" || scheme == "wss" || scheme == "http" || scheme == "https" else { + return nil + } + let tls = scheme == "wss" || scheme == "https" if !tls, !LoopbackHost.isLoopbackHost(hostname) { return nil } - let port = parsed.port ?? (tls ? 443 : 18789) - let bootstrapToken = json["bootstrapToken"] as? String - let token = json["token"] as? String - let password = json["password"] as? String return GatewayConnectDeepLink( host: hostname, - port: port, + port: parsed.port ?? (tls ? 443 : 18789), tls: tls, bootstrapToken: bootstrapToken, token: token, @@ -65,6 +165,19 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { } return Data(base64Encoded: base64) } + + private static func setupCodeCandidates(in input: String) -> [String] { + let surroundingPunctuation = CharacterSet(charactersIn: "`'\"“”‘’()[]{}<>.,;:") + return input + .components(separatedBy: .whitespacesAndNewlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines.union(surroundingPunctuation)) } + .filter { candidate in + guard candidate.count >= 24 else { return false } + return candidate.allSatisfy { ch in + ch.isLetter || ch.isNumber || ch == "-" || ch == "_" || ch == "=" + } + } + } } public struct AgentDeepLink: Codable, Sendable, Equatable { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index 79613b310ff..b58b051c3bd 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -2,6 +2,14 @@ import Foundation import OpenClawKit import Testing +private func setupCode(from payload: String) -> String { + Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} + @Suite struct DeepLinksSecurityTests { @Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() { let url = URL( @@ -31,33 +39,18 @@ import Testing @Test func setupCodeRejectsInsecureNonLoopbackWs() { let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - #expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil) + #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) } @Test func setupCodeRejectsInsecurePrefixBypassHost() { let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - #expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil) + #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) } @Test func setupCodeAllowsLoopbackWs() { let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"# - let encoded = Data(payload.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") #expect( - GatewayConnectDeepLink.fromSetupCode(encoded) == .init( + GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init( host: "127.0.0.1", port: 18789, tls: false, @@ -65,4 +58,62 @@ import Testing token: nil, password: nil)) } + + @Test func setupCodeParsesHostPayload() { + let payload = #"{"host":"gateway.tailnet.ts.net","port":443,"tls":true,"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 setupCodeParsesHostPayloadWithTLSDefaultPort() { + let payload = #"{"host":"gateway.tailnet.ts.net","tls":true,"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 setupCodeRejectsInsecureHostPayload() { + let payload = #"{"host":"gateway.tailnet.ts.net","port":18789,"tls":false,"bootstrapToken":"tok"}"# + #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) + } + + @Test func setupInputParsesFullCopiedSetupMessage() { + let payload = #"{"url":"wss://gateway.tailnet.ts.net","bootstrapToken":"tok"}"# + let message = """ + Pairing setup code generated. + + Setup code: + \(setupCode(from: payload)) + """ + #expect( + GatewayConnectDeepLink.fromSetupInput(message) == .init( + host: "gateway.tailnet.ts.net", + port: 443, + tls: true, + bootstrapToken: "tok", + token: nil, + password: nil)) + } + + @Test func setupInputParsesRawGatewayURL() { + #expect( + GatewayConnectDeepLink.fromSetupInput("wss://gateway.example.com:444") == .init( + host: "gateway.example.com", + port: 444, + tls: true, + bootstrapToken: nil, + token: nil, + password: nil)) + } } diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 81b5e22f23a..fbf3d75e2d2 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -113,7 +113,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire 1. In Telegram, message your bot: `/pair` 2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram). 3. On your phone, open the OpenClaw iOS app → Settings → Gateway. -4. Paste the setup code and connect. +4. Scan the QR code or paste the setup code and connect. 5. Back in Telegram: `/pair pending` (review request IDs, role, and scopes), then approve. The setup code is a base64-encoded JSON payload that contains: @@ -134,6 +134,13 @@ That bootstrap token carries the built-in pairing bootstrap profile: Treat the setup code like a password while it is valid. +For Tailscale, public, or other non-loopback mobile pairing, use Tailscale +Serve/Funnel or another `wss://` Gateway URL. Direct non-loopback `ws://` setup +URLs are rejected before QR/setup-code issuance. Plaintext `ws://` setup codes +are limited to loopback URLs; private-network `ws://` clients still require the explicit +`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` break-glass described in the remote +Gateway guide. + ### Approve a node device ```bash diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index c7d3fa9d736..0afd9e7e09e 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -56,7 +56,12 @@ vi.mock("./notify.js", () => ({ registerPairingNotifierService: vi.fn(), })); -import { approveDevicePairing, listDevicePairing } from "./api.js"; +import { + approveDevicePairing, + listDevicePairing, + resolveGatewayBindUrl, + resolveTailnetHostWithRunner, +} from "./api.js"; import registerDevicePair from "./index.js"; type ListedPendingPairingRequest = Awaited>["pending"][number]; @@ -87,7 +92,7 @@ function createApi(params?: { }, }, pluginConfig: { - publicUrl: "ws://51.79.175.165:18789", + publicUrl: "wss://gateway.example.test", ...params?.pluginConfig, }, runtime: (params?.runtime ?? {}) as OpenClawPluginApi["runtime"], @@ -611,8 +616,17 @@ describe("device-pair /pair default setup code", () => { }); }); - it("normalizes bare publicUrl host ports before issuing setup codes", async () => { + it("normalizes secure bare publicUrl host ports before issuing setup codes", async () => { const command = registerPairCommand({ + config: { + gateway: { + tls: { enabled: true }, + auth: { + mode: "token", + token: "gateway-token", + }, + }, + }, pluginConfig: { publicUrl: "gateway.example.test:18789/setup", }, @@ -628,7 +642,131 @@ describe("device-pair /pair default setup code", () => { const text = requireText(result); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); - expect(text).toContain("Gateway: ws://gateway.example.test:18789"); + expect(text).toContain("Gateway: wss://gateway.example.test:18789"); + }); + + it("allows loopback cleartext setup urls", async () => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl: "ws://127.0.0.1:18789", + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + const text = requireText(result); + + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); + expect(text).toContain("Gateway: ws://127.0.0.1:18789"); + }); + + it("rejects private LAN cleartext setup urls before issuing setup codes", async () => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl: "ws://192.168.1.20:18789", + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(requireText(result)).toContain( + "Mobile pairing over non-loopback networks requires a secure gateway URL", + ); + }); + + it("rejects public cleartext setup urls before issuing setup codes", async () => { + const command = registerPairCommand({ + pluginConfig: { + publicUrl: "ws://gateway.example.test:18789", + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(requireText(result)).toContain( + "Mobile pairing over non-loopback networks requires a secure gateway URL", + ); + }); + + it("rejects tailnet cleartext setup urls before issuing setup codes", async () => { + vi.mocked(resolveGatewayBindUrl).mockReturnValueOnce({ + url: "ws://100.64.0.9:18789", + source: "gateway.bind=tailnet", + }); + const command = registerPairCommand({ + config: { + gateway: { + bind: "tailnet", + auth: { + mode: "token", + token: "gateway-token", + }, + }, + }, + pluginConfig: { + publicUrl: undefined, + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(requireText(result)).toContain("prefer gateway.tailscale.mode=serve"); + }); + + it("uses Tailscale Serve MagicDNS as a secure setup url", async () => { + vi.mocked(resolveTailnetHostWithRunner).mockResolvedValueOnce("gateway.tailnet.ts.net"); + const command = registerPairCommand({ + config: { + gateway: { + tailscale: { mode: "serve" }, + auth: { + mode: "token", + token: "gateway-token", + }, + }, + }, + pluginConfig: { + publicUrl: undefined, + }, + }); + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: ["operator.write", "operator.pairing"], + }), + ); + const text = requireText(result); + + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); + expect(text).toContain("Gateway: wss://gateway.tailnet.ts.net"); }); it("rejects invalid bare publicUrl host ports", async () => { diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 777a63fcab1..b7683919873 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -172,6 +172,47 @@ function parseNormalizedGatewayUrl(raw: string): string | null { } } +function describeSecureMobilePairingFix(source?: string): string { + const sourceNote = source ? ` Resolved source: ${source}.` : ""; + return ( + "Mobile pairing over non-loopback networks requires a secure gateway URL (wss://) or Tailscale Serve/Funnel." + + sourceNote + + " Fix: prefer gateway.tailscale.mode=serve, or set " + + "gateway.remote.url / plugins.entries.device-pair.config.publicUrl to a wss:// URL. " + + "ws:// setup codes are only valid for localhost/loopback or the Android emulator." + ); +} + +function normalizeHostForIpCheck(host: string): string { + let normalized = normalizeLowercaseStringOrEmpty(host); + if (normalized.startsWith("[") && normalized.endsWith("]")) { + normalized = normalized.slice(1, -1); + } + if (normalized.endsWith(".")) { + normalized = normalized.slice(0, -1); + } + const zoneIndex = normalized.indexOf("%"); + if (zoneIndex >= 0) { + normalized = normalized.slice(0, zoneIndex); + } + return normalized; +} + +function isLoopbackHost(host: string): boolean { + const normalized = normalizeHostForIpCheck(host); + if (!normalized) { + return false; + } + if (normalized === "localhost" || normalized === "0.0.0.0" || normalized === "::") { + return true; + } + const octets = parseIPv4Octets(normalized); + if (octets) { + return octets[0] === 127; + } + return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1"; +} + function resolveScheme( cfg: OpenClawPluginApi["config"], opts?: { forceSecure?: boolean }, @@ -187,6 +228,9 @@ function parseIPv4Octets(address: string): [number, number, number, number] | nu if (parts.length !== 4) { return null; } + if (parts.some((part) => !/^\d+$/.test(part))) { + return null; + } const octets = parts.map((part) => Number.parseInt(part, 10)); if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { return null; @@ -221,6 +265,29 @@ function isTailnetIPv4(address: string): boolean { return a === 100 && b >= 64 && b <= 127; } +function isMobilePairingCleartextAllowedHost(host: string): boolean { + const normalized = normalizeHostForIpCheck(host); + return isLoopbackHost(normalized) || normalized === "10.0.2.2"; +} + +function validateMobilePairingUrl(url: string, source?: string): string | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "Resolved mobile pairing URL is invalid."; + } + const protocol = + parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol; + if (protocol === "wss:") { + return null; + } + if (protocol !== "ws:" || isMobilePairingCleartextAllowedHost(parsed.hostname)) { + return null; + } + return describeSecureMobilePairingFix(source); +} + function pickMatchingIPv4(predicate: (address: string) => boolean): string | null { const nets = os.networkInterfaces(); for (const entries of Object.values(nets)) { @@ -362,6 +429,18 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise { + const result = await resolveGatewayUrl(api); + if (!result.url) { + return result; + } + const mobilePairingUrlError = validateMobilePairingUrl(result.url, result.source); + if (mobilePairingUrlError) { + return { error: mobilePairingUrlError }; + } + return result; +} + function encodeSetupCode(payload: SetupPayload): string { const json = JSON.stringify(payload); const base64 = Buffer.from(json, "utf8").toString("base64"); @@ -668,7 +747,7 @@ export default definePluginEntry({ return buildMissingPairingScopeReply(); } - const urlResult = await resolveGatewayUrl(api); + const urlResult = await resolveMobilePairingGatewayUrl(api); if (!urlResult.url) { return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` }; }