Skip to content

Commit a613143

Browse files
authored
fix(macos): improve tailscale gateway discovery (#40167)
Sanitized test tailnet hostnames and re-ran the targeted macOS gateway discovery test suite before merge.
1 parent 92726d9 commit a613143

File tree

7 files changed

+225
-10
lines changed

7 files changed

+225
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
3434
- 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.
3535
- 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.
3636
- 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.
37+
- 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.
3738

3839
## 2026.3.7
3940

apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ enum GatewayDiscoverySelectionSupport {
66
gateway: GatewayDiscoveryModel.DiscoveredGateway,
77
state: AppState)
88
{
9-
if state.remoteTransport == .direct {
10-
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
11-
} else {
12-
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
9+
let preferredTransport = self.preferredTransport(
10+
for: gateway,
11+
current: state.remoteTransport)
12+
if preferredTransport != state.remoteTransport {
13+
state.remoteTransport = preferredTransport
1314
}
15+
16+
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
17+
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
18+
1419
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
1520
OpenClawConfigFile.setRemoteGatewayUrl(
1621
host: endpoint.host,
@@ -19,4 +24,30 @@ enum GatewayDiscoverySelectionSupport {
1924
OpenClawConfigFile.clearRemoteGatewayUrl()
2025
}
2126
}
27+
28+
static func preferredTransport(
29+
for gateway: GatewayDiscoveryModel.DiscoveredGateway,
30+
current: AppState.RemoteTransport) -> AppState.RemoteTransport
31+
{
32+
if self.shouldPreferDirectTransport(for: gateway) {
33+
return .direct
34+
}
35+
return current
36+
}
37+
38+
static func shouldPreferDirectTransport(
39+
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool
40+
{
41+
guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false }
42+
if gateway.stableID.hasPrefix("tailscale-serve|") {
43+
return true
44+
}
45+
guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)?
46+
.trimmingCharacters(in: .whitespacesAndNewlines)
47+
.lowercased()
48+
else {
49+
return false
50+
}
51+
return host.hasSuffix(".ts.net")
52+
}
2253
}

apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -338,13 +338,12 @@ public final class GatewayDiscoveryModel {
338338
var attempt = 0
339339
let startedAt = Date()
340340
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
341-
let hasResults = await MainActor.run {
342-
if self.filterLocalGateways {
343-
return !self.gateways.isEmpty
344-
}
345-
return self.gateways.contains(where: { !$0.isLocal })
341+
let shouldContinue = await MainActor.run {
342+
Self.shouldContinueTailscaleServeDiscovery(
343+
currentGateways: self.gateways,
344+
tailscaleServeGateways: self.tailscaleServeFallbackGateways)
346345
}
347-
if hasResults { return }
346+
if !shouldContinue { return }
348347

349348
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
350349
if !beacons.isEmpty {
@@ -363,6 +362,15 @@ public final class GatewayDiscoveryModel {
363362
}
364363
}
365364

365+
static func shouldContinueTailscaleServeDiscovery(
366+
currentGateways _: [DiscoveredGateway],
367+
tailscaleServeGateways: [DiscoveredGateway]) -> Bool
368+
{
369+
// Tailscale Serve is a parallel discovery source. DNS-SD results should not suppress the
370+
// probe, otherwise Serve-only gateways disappear as soon as any other remote gateway is found.
371+
tailscaleServeGateways.isEmpty
372+
}
373+
366374
private var hasUsableWideAreaResults: Bool {
367375
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
368376
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }

apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ enum TailscaleServeGatewayDiscovery {
203203
let process = Process()
204204
process.executableURL = URL(fileURLWithPath: path)
205205
process.arguments = args
206+
process.environment = self.commandEnvironment()
206207
let outPipe = Pipe()
207208
process.standardOutput = outPipe
208209
process.standardError = FileHandle.nullDevice
@@ -227,6 +228,19 @@ enum TailscaleServeGatewayDiscovery {
227228
return output?.isEmpty == false ? output : nil
228229
}
229230

231+
static func commandEnvironment(
232+
base: [String: String] = ProcessInfo.processInfo.environment) -> [String: String]
233+
{
234+
var env = base
235+
let term = env["TERM"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
236+
if term.isEmpty {
237+
// The macOS Tailscale app binary exits with CLIError error 3 when TERM is missing,
238+
// which is common for GUI-launched app environments.
239+
env["TERM"] = "dumb"
240+
}
241+
return env
242+
}
243+
230244
private static func parseStatus(_ raw: String) -> TailscaleStatus? {
231245
guard let data = raw.data(using: .utf8) else { return nil }
232246
return try? JSONDecoder().decode(TailscaleStatus.self, from: data)

apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,56 @@ struct GatewayDiscoveryModelTests {
121121
port: 2201) == "[email protected]:2201")
122122
}
123123

124+
@Test func `tailscale serve discovery continues when DNS-SD already found a remote gateway`() {
125+
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
126+
displayName: "Nearby Gateway",
127+
serviceHost: "nearby-gateway.local",
128+
servicePort: 18789,
129+
lanHost: "nearby-gateway.local",
130+
tailnetDns: nil,
131+
sshPort: 22,
132+
gatewayPort: 18789,
133+
cliPath: nil,
134+
stableID: "bonjour|nearby-gateway",
135+
debugID: "bonjour",
136+
isLocal: false)
137+
138+
#expect(GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
139+
currentGateways: [dnsSdGateway],
140+
tailscaleServeGateways: []))
141+
}
142+
143+
@Test func `tailscale serve discovery stops after serve result is found`() {
144+
let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway(
145+
displayName: "Nearby Gateway",
146+
serviceHost: "nearby-gateway.local",
147+
servicePort: 18789,
148+
lanHost: "nearby-gateway.local",
149+
tailnetDns: nil,
150+
sshPort: 22,
151+
gatewayPort: 18789,
152+
cliPath: nil,
153+
stableID: "bonjour|nearby-gateway",
154+
debugID: "bonjour",
155+
isLocal: false)
156+
let serveGateway = GatewayDiscoveryModel.DiscoveredGateway(
157+
displayName: "Tailscale Gateway",
158+
serviceHost: "gateway-host.tailnet-example.ts.net",
159+
servicePort: 443,
160+
lanHost: nil,
161+
tailnetDns: "gateway-host.tailnet-example.ts.net",
162+
sshPort: 22,
163+
gatewayPort: 443,
164+
cliPath: nil,
165+
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
166+
debugID: "serve",
167+
isLocal: false)
168+
169+
#expect(!GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery(
170+
currentGateways: [dnsSdGateway],
171+
tailscaleServeGateways: [serveGateway]))
172+
}
173+
124174
@Test func `dedupe key prefers resolved endpoint across sources`() {
125175
let wideArea = GatewayDiscoveryModel.DiscoveredGateway(
126176
displayName: "Gateway",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Foundation
2+
import OpenClawDiscovery
3+
import Testing
4+
@testable import OpenClaw
5+
6+
@Suite(.serialized)
7+
@MainActor
8+
struct GatewayDiscoverySelectionSupportTests {
9+
private func makeGateway(
10+
serviceHost: String?,
11+
servicePort: Int?,
12+
tailnetDns: String? = nil,
13+
sshPort: Int = 22,
14+
stableID: String) -> GatewayDiscoveryModel.DiscoveredGateway
15+
{
16+
GatewayDiscoveryModel.DiscoveredGateway(
17+
displayName: "Gateway",
18+
serviceHost: serviceHost,
19+
servicePort: servicePort,
20+
lanHost: nil,
21+
tailnetDns: tailnetDns,
22+
sshPort: sshPort,
23+
gatewayPort: servicePort,
24+
cliPath: nil,
25+
stableID: stableID,
26+
debugID: UUID().uuidString,
27+
isLocal: false)
28+
}
29+
30+
@Test func `selecting tailscale serve gateway switches to direct transport`() async {
31+
let tailnetHost = "gateway-host.tailnet-example.ts.net"
32+
let configPath = TestIsolation.tempConfigPath()
33+
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
34+
let state = AppState(preview: true)
35+
state.remoteTransport = .ssh
36+
state.remoteTarget = "user@old-host"
37+
38+
GatewayDiscoverySelectionSupport.applyRemoteSelection(
39+
gateway: self.makeGateway(
40+
serviceHost: tailnetHost,
41+
servicePort: 443,
42+
tailnetDns: tailnetHost,
43+
stableID: "tailscale-serve|\(tailnetHost)"),
44+
state: state)
45+
46+
#expect(state.remoteTransport == .direct)
47+
#expect(state.remoteUrl == "wss://\(tailnetHost)")
48+
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == tailnetHost)
49+
}
50+
}
51+
52+
@Test func `selecting merged tailnet gateway still switches to direct transport`() async {
53+
let tailnetHost = "gateway-host.tailnet-example.ts.net"
54+
let configPath = TestIsolation.tempConfigPath()
55+
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
56+
let state = AppState(preview: true)
57+
state.remoteTransport = .ssh
58+
59+
GatewayDiscoverySelectionSupport.applyRemoteSelection(
60+
gateway: self.makeGateway(
61+
serviceHost: tailnetHost,
62+
servicePort: 443,
63+
tailnetDns: tailnetHost,
64+
stableID: "wide-area|openclaw.internal.|gateway-host"),
65+
state: state)
66+
67+
#expect(state.remoteTransport == .direct)
68+
#expect(state.remoteUrl == "wss://\(tailnetHost)")
69+
}
70+
}
71+
72+
@Test func `selecting nearby lan gateway keeps ssh transport`() async {
73+
let configPath = TestIsolation.tempConfigPath()
74+
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
75+
let state = AppState(preview: true)
76+
state.remoteTransport = .ssh
77+
state.remoteTarget = "user@old-host"
78+
79+
GatewayDiscoverySelectionSupport.applyRemoteSelection(
80+
gateway: self.makeGateway(
81+
serviceHost: "nearby-gateway.local",
82+
servicePort: 18789,
83+
stableID: "bonjour|nearby-gateway"),
84+
state: state)
85+
86+
#expect(state.remoteTransport == .ssh)
87+
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local")
88+
}
89+
}
90+
}

apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,25 @@ struct TailscaleServeGatewayDiscoveryTests {
7474
#expect(TailscaleServeGatewayDiscovery
7575
.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil)
7676
}
77+
78+
@Test func `adds TERM for GUI-launched tailscale subprocesses`() {
79+
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
80+
"HOME": "/Users/tester",
81+
"PATH": "/usr/bin:/bin",
82+
])
83+
84+
#expect(env["TERM"] == "dumb")
85+
#expect(env["HOME"] == "/Users/tester")
86+
#expect(env["PATH"] == "/usr/bin:/bin")
87+
}
88+
89+
@Test func `preserves existing TERM when building tailscale subprocess environment`() {
90+
let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [
91+
"TERM": "xterm-256color",
92+
"HOME": "/Users/tester",
93+
])
94+
95+
#expect(env["TERM"] == "xterm-256color")
96+
#expect(env["HOME"] == "/Users/tester")
97+
}
7798
}

0 commit comments

Comments
 (0)