From 8a31a868c0645f3687a3fa1997245e1bcc0bea86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 05:30:40 +0100 Subject: [PATCH] fix: honor tailnet bind for macOS gateway endpoint --- CHANGELOG.md | 1 + .../Clawdbot/GatewayEndpointStore.swift | 70 ++++++- .../GatewayEndpointStoreTests.swift | 174 +++--------------- 3 files changed, 96 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a20d43a..b3cb5e768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixes - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. - TUI: migrate key handling to the updated pi-tui Key matcher API. +- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. ### Maintenance - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index f201544b7..2573dceb1 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -13,12 +13,14 @@ enum GatewayEndpointState: Sendable, Equatable { /// - The endpoint store owns observation + explicit "ensure tunnel" actions. actor GatewayEndpointStore { static let shared = GatewayEndpointStore() + private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] struct Deps: Sendable { let mode: @Sendable () async -> AppState.ConnectionMode let token: @Sendable () -> String? let password: @Sendable () -> String? let localPort: @Sendable () -> Int + let localHost: @Sendable () async -> String let remotePortIfRunning: @Sendable () async -> UInt16? let ensureRemoteTunnel: @Sendable () async throws -> UInt16 @@ -33,6 +35,16 @@ actor GatewayEndpointStore { env: ProcessInfo.processInfo.environment) }, localPort: { GatewayEnvironment.gatewayPort() }, + localHost: { + let root = ClawdbotConfigFile.loadDict() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: root, + env: ProcessInfo.processInfo.environment) + let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + return GatewayEndpointStore.resolveLocalGatewayHost( + bindMode: bind, + tailscaleIP: tailscaleIP) + }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) } @@ -89,13 +101,17 @@ actor GatewayEndpointStore { } let port = deps.localPort() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: ClawdbotConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil) let token = deps.token() let password = deps.password() switch initialMode { case .local: self.state = .ready( mode: .local, - url: URL(string: "ws://127.0.0.1:\(port)")!, + url: URL(string: "ws://\(host):\(port)")!, token: token, password: password) case .remote: @@ -129,9 +145,10 @@ actor GatewayEndpointStore { switch mode { case .local: let port = self.deps.localPort() + let host = await self.deps.localHost() self.setState(.ready( mode: .local, - url: URL(string: "ws://127.0.0.1:\(port)")!, + url: URL(string: "ws://\(host):\(port)")!, token: token, password: password)) case .remote: @@ -218,6 +235,41 @@ actor GatewayEndpointStore { "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") } } + + private static func resolveGatewayBindMode( + root: [String: Any], + env: [String: String]) -> String? + { + if let envBind = env["CLAWDBOT_GATEWAY_BIND"] { + let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + if let gateway = root["gateway"] as? [String: Any], + let bind = gateway["bind"] as? String + { + let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + return nil + } + + private static func resolveLocalGatewayHost( + bindMode: String?, + tailscaleIP: String?) -> String + { + switch bindMode { + case "tailnet": + return tailscaleIP ?? "127.0.0.1" + case "auto": + return tailscaleIP ?? "127.0.0.1" + default: + return "127.0.0.1" + } + } } #if DEBUG @@ -229,5 +281,19 @@ extension GatewayEndpointStore { { self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env) } + + static func _testResolveGatewayBindMode( + root: [String: Any], + env: [String: String]) -> String? + { + self.resolveGatewayBindMode(root: root, env: env) + } + + static func _testResolveLocalGatewayHost( + bindMode: String?, + tailscaleIP: String?) -> String + { + self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP) + } } #endif diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift index 3e284ff57..f10aa5585 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift @@ -1,156 +1,36 @@ -import Foundation import Testing @testable import Clawdbot -@Suite struct GatewayEndpointStoreTests { - private final class ModeBox: @unchecked Sendable { - private let lock = NSLock() - private var value: AppState.ConnectionMode - - init(_ initial: AppState.ConnectionMode) { - self.value = initial - } - - func get() -> AppState.ConnectionMode { - self.lock.lock() - defer { self.lock.unlock() } - return self.value - } - - func set(_ next: AppState.ConnectionMode) { - self.lock.lock() - defer { self.lock.unlock() } - self.value = next - } +@Suite(.serialized) +struct GatewayEndpointStoreTests { + @Test func resolvesLocalHostFromBindModes() { + #expect(GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "loopback", + tailscaleIP: "100.64.0.10") == "127.0.0.1") + #expect(GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "lan", + tailscaleIP: "100.64.0.10") == "127.0.0.1") + #expect(GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "tailnet", + tailscaleIP: "100.64.0.10") == "100.64.0.10") + #expect(GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "tailnet", + tailscaleIP: nil) == "127.0.0.1") + #expect(GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "auto", + tailscaleIP: "100.64.0.10") == "100.64.0.10") + #expect(GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "auto", + tailscaleIP: nil) == "127.0.0.1") } - @Test func localRefreshResolvesToLocalhostPort() async throws { - let mode = ModeBox(.local) - let store = GatewayEndpointStore(deps: .init( - mode: { mode.get() }, - token: { "t" }, - password: { nil }, - localPort: { 1234 }, - remotePortIfRunning: { nil }, - ensureRemoteTunnel: { 18789 })) - - await store.refresh() - let cfg = try await store.requireConfig() - #expect(cfg.url.absoluteString == "ws://127.0.0.1:1234") - #expect(cfg.token == "t") - } - - @Test func remoteWithoutTunnelRecoversByEnsuringTunnel() async throws { - let mode = ModeBox(.remote) - let store = GatewayEndpointStore(deps: .init( - mode: { mode.get() }, - token: { nil }, - password: { nil }, - localPort: { 18789 }, - remotePortIfRunning: { nil }, - ensureRemoteTunnel: { 18789 })) - - let cfg = try await store.requireConfig() - #expect(cfg.url.absoluteString == "ws://127.0.0.1:18789") - #expect(cfg.token == nil) - } - - @Test func ensureRemoteTunnelPublishesReadyState() async throws { - let mode = ModeBox(.remote) - let store = GatewayEndpointStore(deps: .init( - mode: { mode.get() }, - token: { "tok" }, - password: { "pw" }, - localPort: { 1 }, - remotePortIfRunning: { 5555 }, - ensureRemoteTunnel: { 5555 })) - - let stream = await store.subscribe(bufferingNewest: 10) - var iterator = stream.makeAsyncIterator() - - _ = await iterator.next() // initial - _ = try await store.ensureRemoteControlTunnel() - - let next = await iterator.next() - guard case let .ready(mode, url, token, password) = next else { - Issue.record("expected .ready after ensure, got \(String(describing: next))") - return - } - #expect(mode == .remote) - #expect(url.absoluteString == "ws://127.0.0.1:5555") - #expect(token == "tok") - #expect(password == "pw") - } - - @Test func resolvesGatewayPasswordByMode() { - let root: [String: Any] = [ - "gateway": [ - "auth": ["password": " local "], - "remote": ["password": " remote "], - ], - ] - let env: [String: String] = [:] - - #expect(GatewayEndpointStore._testResolveGatewayPassword( - isRemote: false, + @Test func resolvesBindModeFromEnvOrConfig() { + let root: [String: Any] = ["gateway": ["bind": "tailnet"]] + #expect(GatewayEndpointStore._testResolveGatewayBindMode( root: root, - env: env) == "local") - #expect(GatewayEndpointStore._testResolveGatewayPassword( - isRemote: true, + env: [:]) == "tailnet") + #expect(GatewayEndpointStore._testResolveGatewayBindMode( root: root, - env: env) == "remote") - } - - @Test func gatewayPasswordEnvOverridesConfig() { - let root: [String: Any] = [ - "gateway": [ - "auth": ["password": "local"], - "remote": ["password": "remote"], - ], - ] - let env = ["CLAWDBOT_GATEWAY_PASSWORD": " env "] - - #expect(GatewayEndpointStore._testResolveGatewayPassword( - isRemote: false, - root: root, - env: env) == "env") - #expect(GatewayEndpointStore._testResolveGatewayPassword( - isRemote: true, - root: root, - env: env) == "env") - } - - @Test func gatewayPasswordIgnoresWhitespaceValues() { - let root: [String: Any] = [ - "gateway": [ - "auth": ["password": " "], - "remote": ["password": "\n\t"], - ], - ] - let env = ["CLAWDBOT_GATEWAY_PASSWORD": " "] - - #expect(GatewayEndpointStore._testResolveGatewayPassword( - isRemote: false, - root: root, - env: env) == nil) - #expect(GatewayEndpointStore._testResolveGatewayPassword( - isRemote: true, - root: root, - env: env) == nil) - } - - @Test func unconfiguredModeRejectsConfig() async { - let mode = ModeBox(.unconfigured) - let store = GatewayEndpointStore(deps: .init( - mode: { mode.get() }, - token: { nil }, - password: { nil }, - localPort: { 18789 }, - remotePortIfRunning: { nil }, - ensureRemoteTunnel: { 18789 })) - - await #expect(throws: Error.self) { - _ = try await store.requireConfig() - } + env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan") } }