diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 32e04db105b..36e8fc62bd3 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -148,6 +148,27 @@ actor GatewayConnection { } } + let nsError = lastError as NSError + if nsError.domain == URLError.errorDomain, + let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url) + { + await self.configure(url: fallback.url, token: fallback.token, password: fallback.password) + for delayMs in [150, 400, 900] { + try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + do { + guard let client = self.client else { + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) + } + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + lastError = error + } + } + } + throw lastError case .remote: let nsError = error as NSError diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index 7665113012d..dca3d48c3da 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -469,6 +469,35 @@ actor GatewayEndpointStore { } } + func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? { + let mode = await self.deps.mode() + guard mode == .local else { return nil } + + let root = ClawdbotConfigFile.loadDict() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: root, + env: ProcessInfo.processInfo.environment) + guard bind == "auto" else { return nil } + + let currentHost = currentURL.host?.lowercased() ?? "" + guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil } + + let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil } + + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: root, + env: ProcessInfo.processInfo.environment) + let port = self.deps.localPort() + let token = self.deps.token() + let password = self.deps.password() + let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")! + + self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)") + self.setState(.ready(mode: .local, url: url, token: token, password: password)) + return (url, token, password) + } + private static func resolveGatewayBindMode( root: [String: Any], env: [String: String]) -> String? @@ -524,8 +553,10 @@ actor GatewayEndpointStore { tailscaleIP: String?) -> String { switch bindMode { - case "tailnet", "auto": + case "tailnet": tailscaleIP ?? "127.0.0.1" + case "auto": + "127.0.0.1" case "custom": customBindHost ?? "127.0.0.1" default: