diff --git a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift index 4dfe8a2c24a4..8216350f8b6f 100644 --- a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift @@ -237,7 +237,7 @@ struct TailscaleIntegrationSection: View { .foregroundStyle(.secondary) } - if self.effectiveService.isInstalled, !self.effectiveService.isRunning { + if self.effectiveService.isAppInstalled, !self.effectiveService.isRunning { Button("Start Tailscale") { self.effectiveService.openTailscaleApp() } .buttonStyle(.borderedProminent) .controlSize(.small) diff --git a/apps/macos/Sources/OpenClaw/TailscaleService.swift b/apps/macos/Sources/OpenClaw/TailscaleService.swift index 2cefa69d59d4..7676e5bc2f74 100644 --- a/apps/macos/Sources/OpenClaw/TailscaleService.swift +++ b/apps/macos/Sources/OpenClaw/TailscaleService.swift @@ -18,9 +18,12 @@ final class TailscaleService { private let logger = Logger(subsystem: "ai.openclaw", category: "tailscale") - /// Indicates if the Tailscale app is installed on the system. + /// Indicates if a Tailscale installation or active daemon was detected. private(set) var isInstalled = false + /// Indicates if the GUI app is available for app-specific actions. + private(set) var isAppInstalled = false + /// Indicates if Tailscale is currently running. private(set) var isRunning = false @@ -40,12 +43,14 @@ final class TailscaleService { #if DEBUG init( isInstalled: Bool, + isAppInstalled: Bool = false, isRunning: Bool, tailscaleHostname: String? = nil, tailscaleIP: String? = nil, statusError: String? = nil) { self.isInstalled = isInstalled + self.isAppInstalled = isAppInstalled self.isRunning = isRunning self.tailscaleHostname = tailscaleHostname self.tailscaleIP = tailscaleIP @@ -59,7 +64,26 @@ final class TailscaleService { return installed } - private struct TailscaleAPIResponse: Codable { + func checkCLIInstallation() -> Bool { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let candidates = [ + "/usr/local/bin/tailscale", + "/usr/local/bin/tailscaled", + "/opt/homebrew/bin/tailscale", + "/opt/homebrew/bin/tailscaled", + "\(home)/go/bin/tailscale", + "\(home)/go/bin/tailscaled", + ] + let installed = Self.hasExecutableCLI(at: candidates) + self.logger.info("Tailscale CLI installed: \(installed)") + return installed + } + + static func hasExecutableCLI(at candidates: [String]) -> Bool { + candidates.contains { FileManager.default.isExecutableFile(atPath: $0) } + } + + struct TailscaleAPIResponse: Codable { let status: String let deviceName: String let tailnetName: String @@ -102,13 +126,33 @@ final class TailscaleService { func checkTailscaleStatus() async { let previousIP = self.tailscaleIP - self.isInstalled = self.checkAppInstallation() - if !self.isInstalled { - self.isRunning = false - self.tailscaleHostname = nil - self.tailscaleIP = nil - self.statusError = "Tailscale is not installed" - } else if let apiResponse = await fetchTailscaleStatus() { + let appInstalled = self.checkAppInstallation() + let cliInstalled = self.checkCLIInstallation() + let apiResponse = await self.fetchTailscaleStatus() + self.applyStatusEvidence( + appInstalled: appInstalled, + cliInstalled: cliInstalled, + apiResponse: apiResponse, + fallbackIP: TailscaleNetwork.detectTailnetIPv4()) + + if previousIP != self.tailscaleIP { + await GatewayEndpointStore.shared.refresh() + } + } + + func applyStatusEvidence( + appInstalled: Bool, + cliInstalled: Bool, + apiResponse: TailscaleAPIResponse?, + fallbackIP: String?) + { + self.isAppInstalled = appInstalled + self.isInstalled = appInstalled || cliInstalled || apiResponse != nil + // RFC 6598 space is not Tailscale-exclusive. Only trust an interface + // fallback after the app, CLI, or local API proves this installation. + let trustedFallbackIP = self.isInstalled ? fallbackIP : nil + + if let apiResponse { self.isRunning = apiResponse.status.lowercased() == "running" if self.isRunning { @@ -120,7 +164,7 @@ final class TailscaleService { .replacingOccurrences(of: ".tailscale.net", with: "") self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net" - self.tailscaleIP = apiResponse.iPv4 + self.tailscaleIP = apiResponse.iPv4 ?? trustedFallbackIP self.statusError = nil self.logger.info( @@ -130,25 +174,28 @@ final class TailscaleService { self.tailscaleIP = nil self.statusError = "Tailscale is not running" } + } else if let trustedFallbackIP { + self.isRunning = true + self.tailscaleHostname = nil + self.tailscaleIP = trustedFallbackIP + self.statusError = nil + self.logger.info("Tailscale interface IP detected (fallback) ip=\(trustedFallbackIP, privacy: .public)") } else { self.isRunning = false self.tailscaleHostname = nil self.tailscaleIP = nil - self.statusError = "Please start the Tailscale app" - self.logger.info("Tailscale API not responding; app likely not running") - } - - if self.tailscaleIP == nil, let fallback = TailscaleNetwork.detectTailnetIPv4() { - self.tailscaleIP = fallback - if !self.isRunning { - self.isRunning = true + self.statusError = if appInstalled { + "Please start the Tailscale app" + } else if cliInstalled { + "Please start the Tailscale daemon" + } else { + "Tailscale is not installed" + } + if appInstalled { + self.logger.info("Tailscale API not responding; app likely not running") + } else if cliInstalled { + self.logger.info("Tailscale API not responding; CLI daemon likely not running") } - self.statusError = nil - self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)") - } - - if previousIP != self.tailscaleIP { - await GatewayEndpointStore.shared.refresh() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift b/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift index 5ee925364888..52a8e04a2e2d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift @@ -5,6 +5,89 @@ import Testing @Suite(.serialized) @MainActor struct TailscaleIntegrationSectionTests { + @Test func `cli installation requires an executable candidate`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let executable = tempDir.appendingPathComponent("tailscale") + let nonExecutable = tempDir.appendingPathComponent("tailscaled") + defer { try? FileManager.default.removeItem(at: tempDir) } + + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + try Data().write(to: executable) + try Data().write(to: nonExecutable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + + #expect(TailscaleService.hasExecutableCLI(at: [executable.path])) + #expect(!TailscaleService.hasExecutableCLI(at: [nonExecutable.path])) + } + + @Test func `cli-only tailscale status is detected as installed and running`() { + let service = TailscaleService(isInstalled: false, isRunning: false) + service.applyStatusEvidence( + appInstalled: false, + cliInstalled: true, + apiResponse: TailscaleService.TailscaleAPIResponse( + status: "Running", + deviceName: "april", + tailnetName: "tail7a0b9.ts.net", + iPv4: "100.66.5.88"), + fallbackIP: "100.66.5.88") + + #expect(service.isInstalled) + #expect(service.isRunning) + #expect(service.tailscaleHostname == "april.tail7a0b9.ts.net") + #expect(service.tailscaleIP == "100.66.5.88") + #expect(service.statusError == nil) + } + + @Test func `installed cli-only tailscale without a running daemon is detected`() { + let service = TailscaleService(isInstalled: false, isRunning: false) + service.applyStatusEvidence( + appInstalled: false, + cliInstalled: true, + apiResponse: nil, + fallbackIP: nil) + + #expect(service.isInstalled) + #expect(!service.isAppInstalled) + #expect(!service.isRunning) + #expect(service.tailscaleHostname == nil) + #expect(service.tailscaleIP == nil) + #expect(service.statusError == "Please start the Tailscale daemon") + } + + @Test func `shared cgnat address alone is not treated as a tailscale installation`() { + let service = TailscaleService(isInstalled: false, isRunning: false) + service.applyStatusEvidence( + appInstalled: false, + cliInstalled: false, + apiResponse: nil, + fallbackIP: "100.66.5.88") + + #expect(!service.isInstalled) + #expect(!service.isAppInstalled) + #expect(!service.isRunning) + #expect(service.tailscaleHostname == nil) + #expect(service.tailscaleIP == nil) + #expect(service.statusError == "Tailscale is not installed") + } + + @Test func `known cli installation can use interface fallback`() { + let service = TailscaleService(isInstalled: false, isRunning: false) + service.applyStatusEvidence( + appInstalled: false, + cliInstalled: true, + apiResponse: nil, + fallbackIP: "100.66.5.88") + + #expect(service.isInstalled) + #expect(!service.isAppInstalled) + #expect(service.isRunning) + #expect(service.tailscaleHostname == nil) + #expect(service.tailscaleIP == "100.66.5.88") + #expect(service.statusError == nil) + } + @Test func `tailscale section builds body when not installed`() { let service = TailscaleService(isInstalled: false, isRunning: false, statusError: "not installed") var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false) @@ -32,6 +115,7 @@ struct TailscaleIntegrationSectionTests { @Test func `tailscale section builds body for funnel mode`() { let service = TailscaleService( isInstalled: true, + isAppInstalled: true, isRunning: false, tailscaleHostname: nil, tailscaleIP: nil,