From 27f9cd591d1a67d07b8bee2398c73c9bd98a7178 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Dec 2025 01:43:59 +0100 Subject: [PATCH] mac: route remote mode through SSH --- .../Clawdis/ConnectionModeCoordinator.swift | 48 +++++++++++++++++++ .../Sources/Clawdis/ControlChannel.swift | 18 ++++--- apps/macos/Sources/Clawdis/MenuBar.swift | 23 +++++---- .../Sources/Clawdis/RemoteTunnelManager.swift | 35 ++++++++++++++ .../macos/Sources/Clawdis/WebChatWindow.swift | 12 +++++ 5 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift create mode 100644 apps/macos/Sources/Clawdis/RemoteTunnelManager.swift diff --git a/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift b/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift new file mode 100644 index 000000000..b41fce1a2 --- /dev/null +++ b/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift @@ -0,0 +1,48 @@ +import Foundation +import OSLog + +@MainActor +final class ConnectionModeCoordinator { + static let shared = ConnectionModeCoordinator() + + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "connection") + + /// Apply the requested connection mode by starting/stopping local gateway, + /// managing the control-channel SSH tunnel, and cleaning up WebChat tunnels. + func apply(mode: AppState.ConnectionMode, paused: Bool) async { + switch mode { + case .local: + await RemoteTunnelManager.shared.stopAll() + WebChatManager.shared.resetTunnels() + do { + try await ControlChannel.shared.configure(mode: .local) + } catch { + // Control channel will mark itself degraded; nothing else to do here. + self.logger.error("control channel local configure failed: \(error.localizedDescription, privacy: .public)") + } + if paused { + GatewayProcessManager.shared.stop() + } else { + GatewayProcessManager.shared.setActive(true) + } + Task.detached { await PortGuardian.shared.sweep(mode: .local) } + + case .remote: + // Never run a local gateway in remote mode. + GatewayProcessManager.shared.stop() + WebChatManager.shared.resetTunnels() + + do { + _ = try await RemoteTunnelManager.shared.ensureControlTunnel() + let settings = CommandResolver.connectionSettings() + try await ControlChannel.shared.configure(mode: .remote( + target: settings.target, + identity: settings.identity)) + } catch { + self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") + } + + Task.detached { await PortGuardian.shared.sweep(mode: .remote) } + } + } +} diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index ae9507495..f335fa755 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -56,10 +56,8 @@ final class ControlChannel: ObservableObject { private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control") private let gateway = GatewayChannel() - private var gatewayURL: URL { - let port = GatewayEnvironment.gatewayPort() - return URL(string: "ws://127.0.0.1:\(port)")! - } + private var gatewayPort: Int = GatewayEnvironment.gatewayPort() + private var gatewayURL: URL { URL(string: "ws://127.0.0.1:\(self.gatewayPort)")! } private var gatewayToken: String? { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] @@ -78,11 +76,19 @@ final class ControlChannel: ObservableObject { func configure(mode: Mode = .local) async throws { switch mode { case .local: + self.gatewayPort = GatewayEnvironment.gatewayPort() await self.configure() case let .remote(target, identity): - // Remote mode assumed to have an existing tunnel; placeholders retained for future use. + // Create/ensure SSH tunnel, then talk to the forwarded local port. _ = (target, identity) - await self.configure() + do { + let forwarded = try await RemoteTunnelManager.shared.ensureControlTunnel() + self.gatewayPort = Int(forwarded) + await self.configure() + } catch { + self.state = .degraded(error.localizedDescription) + throw error + } } } diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 90af16ee9..68a95cfe2 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -45,7 +45,14 @@ struct ClawdisApp: App { } .onChange(of: self.state.isPaused) { _, paused in self.applyStatusItemAppearance(paused: paused) - self.gatewayManager.setActive(!paused) + if self.state.connectionMode == .local { + self.gatewayManager.setActive(!paused) + } else { + self.gatewayManager.stop() + } + } + .onChange(of: self.state.connectionMode) { _, mode in + Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } } Settings { @@ -161,17 +168,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate self.state = AppStateStore.shared AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) if let state { - GatewayProcessManager.shared.setActive(!state.isPaused) - } - Task { - await ControlChannel.shared.configure() - PresenceReporter.shared.start() + Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } } + Task { PresenceReporter.shared.start() } Task { await HealthStore.shared.refresh(onDemand: true) } - Task { - let mode = AppStateStore.shared.connectionMode - await PortGuardian.shared.sweep(mode: mode) - } + Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } self.startListener() self.scheduleFirstRunOnboardingIfNeeded() @@ -186,6 +187,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate GatewayProcessManager.shared.stop() PresenceReporter.shared.stop() WebChatManager.shared.close() + WebChatManager.shared.resetTunnels() + Task { await RemoteTunnelManager.shared.stopAll() } Task { await AgentRPC.shared.shutdown() } } diff --git a/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift b/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift new file mode 100644 index 000000000..4a65ed4c2 --- /dev/null +++ b/apps/macos/Sources/Clawdis/RemoteTunnelManager.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost. +actor RemoteTunnelManager { + static let shared = RemoteTunnelManager() + + private var controlTunnel: WebChatTunnel? + + /// Ensure an SSH tunnel is running for the gateway control port. + /// Returns the local forwarded port (usually 18789). + func ensureControlTunnel() async throws -> UInt16 { + let settings = CommandResolver.connectionSettings() + guard settings.mode == .remote else { + throw NSError(domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + + if let tunnel = self.controlTunnel, + tunnel.process.isRunning, + let local = tunnel.localPort { + return local + } + + let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) + let tunnel = try await WebChatTunnel.create( + remotePort: GatewayEnvironment.gatewayPort(), + preferredLocalPort: desiredPort) + self.controlTunnel = tunnel + return tunnel.localPort ?? desiredPort + } + + func stopAll() { + self.controlTunnel?.terminate() + self.controlTunnel = nil + } +} diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 2a91d4b1f..4ca97c40f 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -514,6 +514,18 @@ final class WebChatManager { return "+1003" } + @MainActor + func resetTunnels() { + self.browserTunnel?.terminate() + self.browserTunnel = nil + self.windowController?.shutdown() + self.windowController?.close() + self.windowController = nil + self.panelController?.shutdown() + self.panelController?.close() + self.panelController = nil + } + @MainActor func openInBrowser(sessionKey: String) async { let port = AppStateStore.webChatPort