fix: macOS reports CLI-only Tailscale as not installed (#114179)

* fix(macos): detect CLI-only Tailscale

* fix(macos): distinguish Tailscale app and CLI installs

Co-authored-by: Nick <18488428+nickxma@users.noreply.github.com>

* chore(macos): refresh native i18n inventory

* test(pr): provide rg in artifact fixture

* fix(macos): gate Tailscale interface evidence

* chore(pr): drop superseded fixture shim

* chore(macos): realign native i18n inventory

* refactor(macos): keep Tailscale status guidance generic

---------

Co-authored-by: Nick <18488428+nickxma@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Nick
2026-07-28 03:00:18 -05:00
committed by GitHub
parent 75531b2fb7
commit 5d4ff17d7d
3 changed files with 156 additions and 25 deletions

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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,