diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d73a467d9..96c3f694b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman. - Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin. - Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras. - Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone. diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index e8e3ee772ca..41d28b49092 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -134,10 +134,10 @@ extension OnboardingView { if self.gatewayDiscovery.gateways.isEmpty { ProgressView().controlSize(.small) Button("Refresh") { - self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) + self.gatewayDiscovery.refreshRemoteFallbackNow(timeoutSeconds: 5.0) } .buttonStyle(.link) - .help("Retry Tailscale discovery (DNS-SD).") + .help("Retry remote discovery (Tailscale DNS-SD + Serve probe).") } Spacer(minLength: 0) } diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift index 94361421a98..213e59b552c 100644 --- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift @@ -76,6 +76,8 @@ public final class GatewayDiscoveryModel { private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] private var wideAreaFallbackTask: Task? private var wideAreaFallbackGateways: [DiscoveredGateway] = [] + private var tailscaleServeFallbackTask: Task? + private var tailscaleServeFallbackGateways: [DiscoveredGateway] = [] private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery") public init( @@ -111,6 +113,7 @@ public final class GatewayDiscoveryModel { } self.scheduleWideAreaFallback() + self.scheduleTailscaleServeFallback() } public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) { @@ -126,6 +129,23 @@ public final class GatewayDiscoveryModel { } } + public func refreshTailscaleServeFallbackNow(timeoutSeconds: TimeInterval = 5.0) { + Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds) + await MainActor.run { [weak self] in + guard let self else { return } + self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons) + self.recomputeGateways() + } + } + } + + public func refreshRemoteFallbackNow(timeoutSeconds: TimeInterval = 5.0) { + self.refreshWideAreaFallbackNow(timeoutSeconds: timeoutSeconds) + self.refreshTailscaleServeFallbackNow(timeoutSeconds: timeoutSeconds) + } + public func stop() { for browser in self.browsers.values { browser.cancel() @@ -140,6 +160,9 @@ public final class GatewayDiscoveryModel { self.wideAreaFallbackTask?.cancel() self.wideAreaFallbackTask = nil self.wideAreaFallbackGateways = [] + self.tailscaleServeFallbackTask?.cancel() + self.tailscaleServeFallbackTask = nil + self.tailscaleServeFallbackGateways = [] self.gateways = [] self.statusText = "Stopped" } @@ -168,22 +191,45 @@ public final class GatewayDiscoveryModel { } } + private func mapTailscaleServeBeacons( + _ beacons: [TailscaleServeGatewayBeacon]) -> [DiscoveredGateway] + { + beacons.map { beacon in + let stableID = "tailscale-serve|\(beacon.tailnetDns.lowercased())" + let isLocal = Self.isLocalGateway( + lanHost: nil, + tailnetDns: beacon.tailnetDns, + displayName: beacon.displayName, + serviceName: nil, + local: self.localIdentity) + return DiscoveredGateway( + displayName: beacon.displayName, + serviceHost: beacon.host, + servicePort: beacon.port, + lanHost: nil, + tailnetDns: beacon.tailnetDns, + sshPort: 22, + gatewayPort: beacon.port, + cliPath: nil, + stableID: stableID, + debugID: "\(beacon.host):\(beacon.port)", + isLocal: isLocal) + } + } + private func recomputeGateways() { let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self)) let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary - if !primaryFiltered.isEmpty { - self.gateways = primaryFiltered - return - } // Bonjour can return only "local" results for the wide-area domain (or no results at all), - // which makes onboarding look empty even though Tailscale DNS-SD can already see gateways. - guard !self.wideAreaFallbackGateways.isEmpty else { + // and cross-network setups may rely on Tailscale Serve without DNS-SD. + let fallback = self.wideAreaFallbackGateways + self.tailscaleServeFallbackGateways + guard !fallback.isEmpty else { self.gateways = primaryFiltered return } - let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways) + let combined = self.sortedDeduped(gateways: primary + fallback) self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined } @@ -284,6 +330,39 @@ public final class GatewayDiscoveryModel { } } + private func scheduleTailscaleServeFallback() { + if Self.isRunningTests { return } + guard self.tailscaleServeFallbackTask == nil else { return } + self.tailscaleServeFallbackTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + var attempt = 0 + let startedAt = Date() + while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { + let hasResults = await MainActor.run { + if self.filterLocalGateways { + return !self.gateways.isEmpty + } + return self.gateways.contains(where: { !$0.isLocal }) + } + if hasResults { return } + + let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4) + if !beacons.isEmpty { + await MainActor.run { [weak self] in + guard let self else { return } + self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons) + self.recomputeGateways() + } + return + } + + attempt += 1 + let backoff = min(8.0, 0.8 + (Double(attempt) * 0.8)) + try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) + } + } + } + private var hasUsableWideAreaResults: Bool { guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false } guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false } @@ -291,11 +370,25 @@ public final class GatewayDiscoveryModel { return gateways.contains(where: { !$0.isLocal }) } + static func dedupeKey(for gateway: DiscoveredGateway) -> String { + if let host = gateway.serviceHost? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !host.isEmpty, + let port = gateway.servicePort, + port > 0 + { + return "endpoint|\(host):\(port)" + } + return "stable|\(gateway.stableID)" + } + private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] { var seen = Set() let deduped = gateways.filter { gateway in - if seen.contains(gateway.stableID) { return false } - seen.insert(gateway.stableID) + let key = Self.dedupeKey(for: gateway) + if seen.contains(key) { return false } + seen.insert(key) return true } return deduped.sorted { diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift new file mode 100644 index 00000000000..60f79f7bf53 --- /dev/null +++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift @@ -0,0 +1,315 @@ +import Foundation +import OpenClawKit + +struct TailscaleServeGatewayBeacon: Sendable, Equatable { + var displayName: String + var tailnetDns: String + var host: String + var port: Int +} + +enum TailscaleServeGatewayDiscovery { + private static let maxCandidates = 32 + private static let probeConcurrency = 6 + private static let defaultProbeTimeoutSeconds: TimeInterval = 1.6 + + struct DiscoveryContext: Sendable { + var tailscaleStatus: @Sendable () async -> String? + var probeHost: @Sendable (_ host: String, _ timeout: TimeInterval) async -> Bool + + static let live = DiscoveryContext( + tailscaleStatus: { await readTailscaleStatus() }, + probeHost: { host, timeout in + await probeHostForGatewayChallenge(host: host, timeout: timeout) + }) + } + + static func discover( + timeoutSeconds: TimeInterval = 3.0, + context: DiscoveryContext = .live) async -> [TailscaleServeGatewayBeacon] + { + guard timeoutSeconds > 0 else { return [] } + guard let statusJson = await context.tailscaleStatus(), + let status = parseStatus(statusJson) + else { + return [] + } + + let candidates = self.collectCandidates(status: status) + if candidates.isEmpty { return [] } + + let deadline = Date().addingTimeInterval(timeoutSeconds) + let perProbeTimeout = min(self.defaultProbeTimeoutSeconds, max(0.5, timeoutSeconds * 0.45)) + + var byHost: [String: TailscaleServeGatewayBeacon] = [:] + await withTaskGroup(of: TailscaleServeGatewayBeacon?.self) { group in + var index = 0 + let workerCount = min(self.probeConcurrency, candidates.count) + + func submitOne() { + guard index < candidates.count else { return } + let candidate = candidates[index] + index += 1 + group.addTask { + let remaining = deadline.timeIntervalSinceNow + if remaining <= 0 { + return nil + } + let timeout = min(perProbeTimeout, remaining) + let reachable = await context.probeHost(candidate.dnsName, timeout) + if !reachable { + return nil + } + return TailscaleServeGatewayBeacon( + displayName: candidate.displayName, + tailnetDns: candidate.dnsName, + host: candidate.dnsName, + port: 443) + } + } + + for _ in 0.. [Candidate] { + let selfDns = normalizeDnsName(status.selfNode?.dnsName) + var out: [Candidate] = [] + var seen = Set() + + for node in status.peer.values { + if node.online == false { + continue + } + guard let dnsName = normalizeDnsName(node.dnsName) else { + continue + } + if dnsName == selfDns { + continue + } + if seen.contains(dnsName) { + continue + } + seen.insert(dnsName) + + out.append(Candidate( + dnsName: dnsName, + displayName: displayName(hostName: node.hostName, dnsName: dnsName))) + + if out.count >= self.maxCandidates { + break + } + } + + return out + } + + private static func displayName(hostName: String?, dnsName: String) -> String { + if let hostName { + let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return dnsName + .split(separator: ".") + .first + .map(String.init) ?? dnsName + } + + private static func normalizeDnsName(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + let withoutDot = trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed + let lower = withoutDot.lowercased() + return lower.isEmpty ? nil : lower + } + + private static func readTailscaleStatus() async -> String? { + let candidates = [ + "/usr/local/bin/tailscale", + "/opt/homebrew/bin/tailscale", + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + "tailscale", + ] + + for candidate in candidates { + guard let executable = self.resolveExecutablePath(candidate) else { continue } + if let stdout = await self.run(path: executable, args: ["status", "--json"], timeout: 1.0) { + return stdout + } + } + + return nil + } + + static func resolveExecutablePath( + _ candidate: String, + env: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let fileManager = FileManager.default + let hasPathSeparator = trimmed.contains("/") + if hasPathSeparator { + return fileManager.isExecutableFile(atPath: trimmed) ? trimmed : nil + } + + let pathRaw = env["PATH"] ?? "" + let entries = pathRaw.split(separator: ":").map(String.init) + for entry in entries { + let dir = entry.trimmingCharacters(in: .whitespacesAndNewlines) + if dir.isEmpty { continue } + let fullPath = URL(fileURLWithPath: dir) + .appendingPathComponent(trimmed) + .path + if fileManager.isExecutableFile(atPath: fullPath) { + return fullPath + } + } + + return nil + } + + private static func run(path: String, args: [String], timeout: TimeInterval) async -> String? { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume(returning: self.runBlocking(path: path, args: args, timeout: timeout)) + } + } + } + + private static func runBlocking(path: String, args: [String], timeout: TimeInterval) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = args + let outPipe = Pipe() + process.standardOutput = outPipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.02) + } + if process.isRunning { + process.terminate() + } + process.waitUntilExit() + + let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return output?.isEmpty == false ? output : nil + } + + private static func parseStatus(_ raw: String) -> TailscaleStatus? { + guard let data = raw.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(TailscaleStatus.self, from: data) + } + + private static func probeHostForGatewayChallenge(host: String, timeout: TimeInterval) async -> Bool { + var components = URLComponents() + components.scheme = "wss" + components.host = host + guard let url = components.url else { return false } + + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = max(0.5, timeout) + config.timeoutIntervalForResource = max(0.5, timeout) + let session = URLSession(configuration: config) + let task = session.webSocketTask(with: url) + task.resume() + + defer { + task.cancel(with: .goingAway, reason: nil) + session.invalidateAndCancel() + } + + do { + return try await AsyncTimeout.withTimeout( + seconds: timeout, + onTimeout: { NSError(domain: "TailscaleServeDiscovery", code: 1, userInfo: nil) }, + operation: { + while true { + let message = try await task.receive() + if isConnectChallenge(message: message) { + return true + } + } + }) + } catch { + return false + } + } + + private static func isConnectChallenge(message: URLSessionWebSocketTask.Message) -> Bool { + let data: Data + switch message { + case let .data(value): + data = value + case let .string(value): + guard let encoded = value.data(using: .utf8) else { return false } + data = encoded + @unknown default: + return false + } + + guard let object = try? JSONSerialization.jsonObject(with: data), + let dict = object as? [String: Any], + let type = dict["type"] as? String, + type == "event", + let event = dict["event"] as? String + else { + return false + } + + return event == "connect.challenge" + } +} + +private struct TailscaleStatus: Decodable { + struct Node: Decodable { + let dnsName: String? + let hostName: String? + let online: Bool? + + private enum CodingKeys: String, CodingKey { + case dnsName = "DNSName" + case hostName = "HostName" + case online = "Online" + } + } + + let selfNode: Node? + let peer: [String: Node] + + private enum CodingKeys: String, CodingKey { + case selfNode = "Self" + case peer = "Peer" + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift index 02888c73870..bbafce58c66 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift @@ -1,4 +1,4 @@ -import OpenClawDiscovery +@testable import OpenClawDiscovery import Testing @Suite @@ -121,4 +121,50 @@ struct GatewayDiscoveryModelTests { host: "studio.local", port: 2201) == "peter@studio.local:2201") } + + @Test func dedupeKeyPrefersResolvedEndpointAcrossSources() { + let wideArea = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Gateway", + serviceHost: "gateway-host.tailnet-example.ts.net", + servicePort: 443, + lanHost: nil, + tailnetDns: "gateway-host.tailnet-example.ts.net", + sshPort: 22, + gatewayPort: 443, + cliPath: nil, + stableID: "wide-area|openclaw.internal.|gateway-host", + debugID: "wide-area", + isLocal: false) + let serve = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Gateway", + serviceHost: "gateway-host.tailnet-example.ts.net", + servicePort: 443, + lanHost: nil, + tailnetDns: "gateway-host.tailnet-example.ts.net", + sshPort: 22, + gatewayPort: 443, + cliPath: nil, + stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net", + debugID: "serve", + isLocal: false) + + #expect(GatewayDiscoveryModel.dedupeKey(for: wideArea) == GatewayDiscoveryModel.dedupeKey(for: serve)) + } + + @Test func dedupeKeyFallsBackToStableIDWithoutEndpoint() { + let unresolved = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Gateway", + serviceHost: nil, + servicePort: nil, + lanHost: nil, + tailnetDns: "gateway-host.tailnet-example.ts.net", + sshPort: 22, + gatewayPort: nil, + cliPath: nil, + stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net", + debugID: "serve", + isLocal: false) + + #expect(GatewayDiscoveryModel.dedupeKey(for: unresolved) == "stable|tailscale-serve|gateway-host.tailnet-example.ts.net") + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift b/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift new file mode 100644 index 00000000000..78c660622b0 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import OpenClawDiscovery + +@Suite +struct TailscaleServeGatewayDiscoveryTests { + @Test func discoversServeGatewayFromTailnetPeers() async { + let statusJson = """ + { + "Self": { + "DNSName": "local-mac.tailnet-example.ts.net.", + "HostName": "local-mac", + "Online": true + }, + "Peer": { + "peer-1": { + "DNSName": "gateway-host.tailnet-example.ts.net.", + "HostName": "gateway-host", + "Online": true + }, + "peer-2": { + "DNSName": "offline.tailnet-example.ts.net.", + "HostName": "offline-box", + "Online": false + }, + "peer-3": { + "DNSName": "local-mac.tailnet-example.ts.net.", + "HostName": "local-mac", + "Online": true + } + } + } + """ + + let context = TailscaleServeGatewayDiscovery.DiscoveryContext( + tailscaleStatus: { statusJson }, + probeHost: { host, _ in + host == "gateway-host.tailnet-example.ts.net" + }) + + let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context) + #expect(beacons.count == 1) + #expect(beacons.first?.displayName == "gateway-host") + #expect(beacons.first?.tailnetDns == "gateway-host.tailnet-example.ts.net") + #expect(beacons.first?.host == "gateway-host.tailnet-example.ts.net") + #expect(beacons.first?.port == 443) + } + + @Test func returnsEmptyWhenStatusUnavailable() async { + let context = TailscaleServeGatewayDiscovery.DiscoveryContext( + tailscaleStatus: { nil }, + probeHost: { _, _ in true }) + + let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context) + #expect(beacons.isEmpty) + } + + @Test func resolvesBareExecutableFromPATH() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let executable = tempDir.appendingPathComponent("tailscale") + try "#!/bin/sh\necho ok\n".write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + let env: [String: String] = ["PATH": tempDir.path] + let resolved = TailscaleServeGatewayDiscovery.resolveExecutablePath("tailscale", env: env) + #expect(resolved == executable.path) + } + + @Test func rejectsMissingExecutableCandidate() { + #expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("", env: [:]) == nil) + #expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil) + } +}