fix: honor tailnet bind for macOS gateway endpoint

This commit is contained in:
Peter Steinberger
2026-01-05 05:30:40 +01:00
parent 870473be85
commit 8a31a868c0
3 changed files with 96 additions and 149 deletions

View File

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

View File

@@ -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<String> = ["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

View File

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