fix(macos): improve tailscale gateway discovery (#40167)

Sanitized test tailnet hostnames and re-ran the targeted macOS gateway discovery test suite before merge.
This commit is contained in:
Nimrod Gutman 2026-03-08 21:49:42 +02:00 committed by GitHub
parent 92726d9863
commit a6131438ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 225 additions and 10 deletions

View File

@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.
- Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.
- Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.
- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.
## 2026.3.7

View File

@ -6,11 +6,16 @@ enum GatewayDiscoverySelectionSupport {
gateway: GatewayDiscoveryModel.DiscoveredGateway,
state: AppState)
{
if state.remoteTransport == .direct {
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
let preferredTransport = self.preferredTransport(
for: gateway,
current: state.remoteTransport)
if preferredTransport != state.remoteTransport {
state.remoteTransport = preferredTransport
}
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
@ -19,4 +24,30 @@ enum GatewayDiscoverySelectionSupport {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
}
static func preferredTransport(
for gateway: GatewayDiscoveryModel.DiscoveredGateway,
current: AppState.RemoteTransport) -> AppState.RemoteTransport
{
if self.shouldPreferDirectTransport(for: gateway) {
return .direct
}
return current
}
static func shouldPreferDirectTransport(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool
{
guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false }
if gateway.stableID.hasPrefix("tailscale-serve|") {
return true
}
guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
else {
return false
}
return host.hasSuffix(".ts.net")
}
}

View File

@ -338,13 +338,12 @@ public final class GatewayDiscoveryModel {
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 })
let shouldContinue = await MainActor.run {
Self.shouldContinueTailscaleServeDiscovery(
currentGateways: self.gateways,
tailscaleServeGateways: self.tailscaleServeFallbackGateways)
}
if hasResults { return }
if !shouldContinue { return }
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
if !beacons.isEmpty {
@ -363,6 +362,15 @@ public final class GatewayDiscoveryModel {
}
}
static func shouldContinueTailscaleServeDiscovery(
currentGateways _: [DiscoveredGateway],
tailscaleServeGateways: [DiscoveredGateway]) -> Bool
{
// Tailscale Serve is a parallel discovery source. DNS-SD results should not suppress the
// probe, otherwise Serve-only gateways disappear as soon as any other remote gateway is found.
tailscaleServeGateways.isEmpty
}
private var hasUsableWideAreaResults: Bool {
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }

View File

@ -203,6 +203,7 @@ enum TailscaleServeGatewayDiscovery {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = args
process.environment = self.commandEnvironment()
let outPipe = Pipe()
process.standardOutput = outPipe
process.standardError = FileHandle.nullDevice
@ -227,6 +228,19 @@ enum TailscaleServeGatewayDiscovery {
return output?.isEmpty == false ? output : nil
}
static func commandEnvironment(
base: [String: String] = ProcessInfo.processInfo.environment) -> [String: String]
{
var env = base
let term = env["TERM"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if term.isEmpty {
// The macOS Tailscale app binary exits with CLIError error 3 when TERM is missing,
// which is common for GUI-launched app environments.
env["TERM"] = "dumb"
}
return env
}
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)

View File

@ -121,6 +121,56 @@ struct GatewayDiscoveryModelTests {
port: 2201) == "peter@studio.local:2201")
}
@Test func `tailscale serve discovery continues when DNS-SD already found a remote gateway`() {
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Nearby Gateway",
serviceHost: "nearby-gateway.local",
servicePort: 18789,
lanHost: "nearby-gateway.local",
tailnetDns: nil,
sshPort: 22,
gatewayPort: 18789,
cliPath: nil,
stableID: "bonjour|nearby-gateway",
debugID: "bonjour",
isLocal: false)
#expect(GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
currentGateways: [dnsSdGateway],
tailscaleServeGateways: []))
}
@Test func `tailscale serve discovery stops after serve result is found`() {
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Nearby Gateway",
serviceHost: "nearby-gateway.local",
servicePort: 18789,
lanHost: "nearby-gateway.local",
tailnetDns: nil,
sshPort: 22,
gatewayPort: 18789,
cliPath: nil,
stableID: "bonjour|nearby-gateway",
debugID: "bonjour",
isLocal: false)
let serveGateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Tailscale 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.shouldContinueTailscaleServeDiscovery(
currentGateways: [dnsSdGateway],
tailscaleServeGateways: [serveGateway]))
}
@Test func `dedupe key prefers resolved endpoint across sources`() {
let wideArea = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Gateway",

View File

@ -0,0 +1,90 @@
import Foundation
import OpenClawDiscovery
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct GatewayDiscoverySelectionSupportTests {
private func makeGateway(
serviceHost: String?,
servicePort: Int?,
tailnetDns: String? = nil,
sshPort: Int = 22,
stableID: String) -> GatewayDiscoveryModel.DiscoveredGateway
{
GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Gateway",
serviceHost: serviceHost,
servicePort: servicePort,
lanHost: nil,
tailnetDns: tailnetDns,
sshPort: sshPort,
gatewayPort: servicePort,
cliPath: nil,
stableID: stableID,
debugID: UUID().uuidString,
isLocal: false)
}
@Test func `selecting tailscale serve gateway switches to direct transport`() async {
let tailnetHost = "gateway-host.tailnet-example.ts.net"
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteTarget = "user@old-host"
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
serviceHost: tailnetHost,
servicePort: 443,
tailnetDns: tailnetHost,
stableID: "tailscale-serve|\(tailnetHost)"),
state: state)
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "wss://\(tailnetHost)")
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == tailnetHost)
}
}
@Test func `selecting merged tailnet gateway still switches to direct transport`() async {
let tailnetHost = "gateway-host.tailnet-example.ts.net"
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
serviceHost: tailnetHost,
servicePort: 443,
tailnetDns: tailnetHost,
stableID: "wide-area|openclaw.internal.|gateway-host"),
state: state)
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "wss://\(tailnetHost)")
}
}
@Test func `selecting nearby lan gateway keeps ssh transport`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
let state = AppState(preview: true)
state.remoteTransport = .ssh
state.remoteTarget = "user@old-host"
GatewayDiscoverySelectionSupport.applyRemoteSelection(
gateway: self.makeGateway(
serviceHost: "nearby-gateway.local",
servicePort: 18789,
stableID: "bonjour|nearby-gateway"),
state: state)
#expect(state.remoteTransport == .ssh)
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local")
}
}
}

View File

@ -74,4 +74,25 @@ struct TailscaleServeGatewayDiscoveryTests {
#expect(TailscaleServeGatewayDiscovery
.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil)
}
@Test func `adds TERM for GUI-launched tailscale subprocesses`() {
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
"HOME": "/Users/tester",
"PATH": "/usr/bin:/bin",
])
#expect(env["TERM"] == "dumb")
#expect(env["HOME"] == "/Users/tester")
#expect(env["PATH"] == "/usr/bin:/bin")
}
@Test func `preserves existing TERM when building tailscale subprocess environment`() {
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
"TERM": "xterm-256color",
"HOME": "/Users/tester",
])
#expect(env["TERM"] == "xterm-256color")
#expect(env["HOME"] == "/Users/tester")
}
}