diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb3b6d78..7edbb7a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ - TUI: add `/elev` alias for `/elevated`. - Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns). - macOS: keep app connection settings local in remote mode to avoid overwriting gateway config. Thanks @ngutman for PR #310. +- macOS: honor discovered gateway ports (Bonjour TXT) so remote tunnels connect to the right ports. Thanks @kkarimi for PR #375. - macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable). - macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. - macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets. diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift index 28e524b4b..433b3e1c8 100644 --- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift +++ b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift @@ -124,17 +124,28 @@ enum ClawdbotConfigFile { } static func remoteGatewayPort() -> Int? { - let root = self.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let raw = remote["url"] as? String + guard let url = self.remoteGatewayUrl(), + let port = url.port, + port > 0 + else { return nil } + return port + } + + static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { + let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSshHost.isEmpty, + let url = self.remoteGatewayUrl(), + let port = url.port, + port > 0, + let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !urlHost.isEmpty else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port, port > 0 else { - return nil - } + + let sshKey = Self.hostKey(trimmedSshHost) + let urlKey = Self.hostKey(urlHost) + guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil } return port } @@ -152,6 +163,30 @@ enum ClawdbotConfigFile { } } + private static func remoteGatewayUrl() -> URL? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let raw = remote["url"] as? String + else { + return nil + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + return url + } + + private static func hostKey(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + if trimmed.contains(":") { return trimmed } + let digits = CharacterSet(charactersIn: "0123456789.") + if trimmed.rangeOfCharacter(from: digits.inverted) == nil { + return trimmed + } + return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + } + private static func parseConfigData(_ data: Data) -> [String: Any]? { if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return root diff --git a/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift b/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift new file mode 100644 index 000000000..da96723a1 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift @@ -0,0 +1,27 @@ +import Foundation + +enum GatewayAgentChannel: String, CaseIterable, Sendable { + case last + case webchat + case whatsapp + case telegram + + init(raw: String?) { + let trimmed = raw? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + self = GatewayAgentChannel(rawValue: trimmed) ?? .last + } + + func shouldDeliver(_ isLast: Bool) -> Bool { + switch self { + case .webchat: + return false + case .last: + return isLast + case .whatsapp, .telegram: + return true + } + } +} + diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index ac4dc9b0e..12f268511 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -204,7 +204,11 @@ final class MacNodeModeCoordinator { static func remoteBridgePort() -> Int { let fallback = Int(Self.loopbackBridgePort() ?? 18790) - let base = ClawdbotConfigFile.remoteGatewayPort() ?? GatewayEnvironment.gatewayPort() + let settings = CommandResolver.connectionSettings() + let sshHost = CommandResolver.parseSSHTarget(settings.target)?.host ?? "" + let base = + ClawdbotConfigFile.remoteGatewayPort(matchingHost: sshHost) ?? + GatewayEnvironment.gatewayPort() guard base > 0 else { return fallback } return Self.derivePort(base: base, offset: 1, fallback: fallback) } diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift index dbb3d44fe..cf0818b28 100644 --- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift +++ b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift @@ -131,42 +131,9 @@ final class RemotePortTunnel { } private static func resolveRemotePortOverride(for sshHost: String) -> Int? { - let root = ClawdbotConfigFile.loadDict() - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let urlRaw = remote["url"] as? String - else { - return nil - } - let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else { - return nil - } - guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), - !host.isEmpty - else { - return nil - } - let sshKey = Self.hostKey(sshHost) - let urlKey = Self.hostKey(host) - guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } - guard sshKey == urlKey else { - Self.logger.debug( - "remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)") - return nil - } - return port - } - - private static func hostKey(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return "" } - if trimmed.contains(":") { return trimmed } - let digits = CharacterSet(charactersIn: "0123456789.") - if trimmed.rangeOfCharacter(from: digits.inverted) == nil { - return trimmed - } - return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + let trimmed = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return ClawdbotConfigFile.remoteGatewayPort(matchingHost: trimmed) } private static func findPort(preferred: UInt16?) async throws -> UInt16 { diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift index c78a2361c..b976541f6 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift @@ -16,6 +16,52 @@ struct ClawdbotConfigFileTests { } } + @MainActor + @Test + func remoteGatewayPortParsesAndMatchesHost() { + let override = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdbot-config-\(UUID().uuidString)") + .appendingPathComponent("clawdbot.json") + .path + + self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) { + ClawdbotConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "ws://bridge.ts.net:19999", + ], + ], + ]) + #expect(ClawdbotConfigFile.remoteGatewayPort() == 19999) + #expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge.ts.net") == 19999) + #expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge") == 19999) + #expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil) + } + } + + @MainActor + @Test + func setRemoteGatewayUrlPreservesScheme() { + let override = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdbot-config-\(UUID().uuidString)") + .appendingPathComponent("clawdbot.json") + .path + + self.withEnv("CLAWDBOT_CONFIG_PATH", value: override) { + ClawdbotConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "wss://old-host:111", + ], + ], + ]) + ClawdbotConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222) + let root = ClawdbotConfigFile.loadDict() + let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String + #expect(url == "wss://new-host:2222") + } + } + @Test func stateDirOverrideSetsConfigPath() { let dir = FileManager.default.temporaryDirectory diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift index aa235e6f4..16614cfa3 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift @@ -69,11 +69,13 @@ struct GatewayDiscoveryModelTests { "lanHost": " studio.local ", "tailnetDns": " peters-mac-studio-1.ts.net ", "sshPort": " 2222 ", + "gatewayPort": " 18799 ", "cliPath": " /opt/clawdbot " ]) #expect(parsed.lanHost == "studio.local") #expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net") #expect(parsed.sshPort == 2222) + #expect(parsed.gatewayPort == 18799) #expect(parsed.cliPath == "/opt/clawdbot") } @@ -81,11 +83,13 @@ struct GatewayDiscoveryModelTests { let parsed = GatewayDiscoveryModel.parseGatewayTXT([ "lanHost": " ", "tailnetDns": "\n", + "gatewayPort": "nope", "sshPort": "nope" ]) #expect(parsed.lanHost == nil) #expect(parsed.tailnetDns == nil) #expect(parsed.sshPort == 22) + #expect(parsed.gatewayPort == nil) #expect(parsed.cliPath == nil) } diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeDiscoveryTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeDiscoveryTests.swift index 90d58ba9f..0ba28918a 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeDiscoveryTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeDiscoveryTests.swift @@ -45,6 +45,70 @@ import Testing let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4) #expect(ok == false) } + + @MainActor + @Test func remoteBridgePortUsesMatchingRemoteUrlPort() { + let configPath = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdbot-config-\(UUID().uuidString)") + .appendingPathComponent("clawdbot.json") + .path + + let defaults = UserDefaults.standard + let prevTarget = defaults.string(forKey: remoteTargetKey) + defer { + if let prevTarget { + defaults.set(prevTarget, forKey: remoteTargetKey) + } else { + defaults.removeObject(forKey: remoteTargetKey) + } + } + + withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) { + withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") { + defaults.set("user@bridge.ts.net", forKey: remoteTargetKey) + ClawdbotConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "ws://bridge.ts.net:25000", + ], + ], + ]) + #expect(MacNodeModeCoordinator.remoteBridgePort() == 25001) + } + } + } + + @MainActor + @Test func remoteBridgePortFallsBackWhenRemoteUrlHostMismatch() { + let configPath = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdbot-config-\(UUID().uuidString)") + .appendingPathComponent("clawdbot.json") + .path + + let defaults = UserDefaults.standard + let prevTarget = defaults.string(forKey: remoteTargetKey) + defer { + if let prevTarget { + defaults.set(prevTarget, forKey: remoteTargetKey) + } else { + defaults.removeObject(forKey: remoteTargetKey) + } + } + + withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) { + withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") { + defaults.set("user@other.ts.net", forKey: remoteTargetKey) + ClawdbotConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "ws://bridge.ts.net:25000", + ], + ], + ]) + #expect(MacNodeModeCoordinator.remoteBridgePort() == 20001) + } + } + } } private struct TestError: Error {