diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift index 46a685865..28e524b4b 100644 --- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift +++ b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift @@ -123,6 +123,35 @@ enum ClawdbotConfigFile { return nil } + 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 + 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 + } + return port + } + + static func setRemoteGatewayUrl(host: String, port: Int?) { + guard let port, port > 0 else { return } + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { return } + self.updateGatewayDict { gateway in + var remote = gateway["remote"] as? [String: Any] ?? [:] + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let scheme = URL(string: existingUrl)?.scheme ?? "ws" + remote["url"] = "\(scheme)://\(trimmedHost):\(port)" + gateway["remote"] = remote + } + } + 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/GatewayDiscoveryModel.swift b/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift index 700be761a..5770670d1 100644 --- a/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift @@ -18,6 +18,7 @@ final class GatewayDiscoveryModel { var lanHost: String? var tailnetDns: String? var sshPort: Int + var gatewayPort: Int? var cliPath: String? var stableID: String var debugID: String @@ -138,6 +139,7 @@ final class GatewayDiscoveryModel { lanHost: parsedTXT.lanHost, tailnetDns: parsedTXT.tailnetDns, sshPort: parsedTXT.sshPort, + gatewayPort: parsedTXT.gatewayPort, cliPath: parsedTXT.cliPath, stableID: stableID, debugID: BridgeEndpointID.prettyDescription(result.endpoint), @@ -207,11 +209,12 @@ final class GatewayDiscoveryModel { } static func parseGatewayTXT(_ txt: [String: String]) - -> (lanHost: String?, tailnetDns: String?, sshPort: Int, cliPath: String?) + -> (lanHost: String?, tailnetDns: String?, sshPort: Int, gatewayPort: Int?, cliPath: String?) { var lanHost: String? var tailnetDns: String? var sshPort = 22 + var gatewayPort: Int? var cliPath: String? if let value = txt["lanHost"] { @@ -228,12 +231,18 @@ final class GatewayDiscoveryModel { { sshPort = parsed } + if let value = txt["gatewayPort"], + let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), + parsed > 0 + { + gatewayPort = parsed + } if let value = txt["cliPath"] { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) cliPath = trimmed.isEmpty ? nil : trimmed } - return (lanHost, tailnetDns, sshPort, cliPath) + return (lanHost, tailnetDns, sshPort, gatewayPort, cliPath) } static func buildSSHTarget(user: String, host: String, port: Int) -> String { diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 1c4dd9743..d4e3476fd 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -694,6 +694,7 @@ extension GeneralSettings { host: host, port: gateway.sshPort) self.state.remoteCliPath = gateway.cliPath ?? "" + ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort) } } diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index 773cfc521..ac4dc9b0e 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -202,6 +202,19 @@ final class MacNodeModeCoordinator { return 18790 } + static func remoteBridgePort() -> Int { + let fallback = Int(Self.loopbackBridgePort() ?? 18790) + let base = ClawdbotConfigFile.remoteGatewayPort() ?? GatewayEnvironment.gatewayPort() + guard base > 0 else { return fallback } + return Self.derivePort(base: base, offset: 1, fallback: fallback) + } + + private static func derivePort(base: Int, offset: Int, fallback: Int) -> Int { + let derived = base + offset + guard derived > 0, derived <= Int(UInt16.max) else { return fallback } + return derived + } + static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool { let connection = NWConnection(to: endpoint, using: .tcp) let stream = Self.makeStateStream(for: connection) @@ -269,7 +282,10 @@ final class MacNodeModeCoordinator { if mode == .remote { do { if self.tunnel == nil || self.tunnel?.process.isRunning == false { - self.tunnel = try await RemotePortTunnel.create(remotePort: 18790) + let remotePort = Self.remoteBridgePort() + self.tunnel = try await RemotePortTunnel.create( + remotePort: remotePort, + allowRemoteUrlOverride: false) } if let localPort = self.tunnel?.localPort, let port = NWEndpoint.Port(rawValue: localPort) diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift index 63063beae..fb661edd1 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift @@ -29,6 +29,7 @@ extension OnboardingView { user: user, host: host, port: gateway.sshPort) + ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort) } self.state.remoteCliPath = gateway.cliPath ?? "" diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift index 87db4b070..64d22ffef 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift @@ -12,6 +12,7 @@ extension OnboardingView { lanHost: "bridge.local", tailnetDns: "bridge.ts.net", sshPort: 2222, + gatewayPort: 18789, cliPath: "/usr/local/bin/clawdbot", stableID: "bridge-1", debugID: "bridge-1", diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift index d03a446ef..dbb3d44fe 100644 --- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift +++ b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift @@ -38,7 +38,11 @@ final class RemotePortTunnel { Task { await PortGuardian.shared.removeRecord(pid: pid) } } - static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> RemotePortTunnel { + static func create( + remotePort: Int, + preferredLocalPort: UInt16? = nil, + allowRemoteUrlOverride: Bool = true + ) async throws -> RemotePortTunnel { let settings = CommandResolver.connectionSettings() guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { throw NSError( @@ -49,7 +53,7 @@ final class RemotePortTunnel { let localPort = try await Self.findPort(preferred: preferredLocalPort) let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) - let remotePortOverride = Self.resolveRemotePortOverride(for: sshHost) + let remotePortOverride = allowRemoteUrlOverride ? Self.resolveRemotePortOverride(for: sshHost) : nil let resolvedRemotePort = remotePortOverride ?? remotePort if let override = remotePortOverride { Self.logger.info(