feat(macos): add direct gateway transport

This commit is contained in:
Nimrod Gutman
2026-01-24 17:24:12 +02:00
committed by Peter Steinberger
parent 2c5141d7df
commit 5330595a5a
11 changed files with 512 additions and 151 deletions

View File

@@ -24,6 +24,11 @@ final class AppState {
case remote case remote
} }
enum RemoteTransport: String {
case ssh
case direct
}
var isPaused: Bool { var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
} }
@@ -166,6 +171,10 @@ final class AppState {
} }
} }
var remoteTransport: RemoteTransport {
didSet { self.syncGatewayConfigIfNeeded() }
}
var canvasEnabled: Bool { var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
} }
@@ -200,6 +209,10 @@ final class AppState {
} }
} }
var remoteUrl: String {
didSet { self.syncGatewayConfigIfNeeded() }
}
var remoteIdentity: String { var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
} }
@@ -265,11 +278,14 @@ final class AppState {
let configRoot = ClawdbotConfigFile.loadDict() let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any] let configGateway = configRoot["gateway"] as? [String: Any]
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configRemoteTransport = AppState.remoteTransport(from: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
self.connectionMode = resolvedConnectionMode self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if resolvedConnectionMode == .remote, if resolvedConnectionMode == .remote,
configRemoteTransport != .direct,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl) let host = AppState.remoteHost(from: configRemoteUrl)
{ {
@@ -277,6 +293,7 @@ final class AppState {
} else { } else {
self.remoteTarget = storedRemoteTarget self.remoteTarget = storedRemoteTarget
} }
self.remoteUrl = configRemoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -358,6 +375,7 @@ final class AppState {
let hasRemoteUrl = !(remoteUrl? let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let remoteTransport = AppState.remoteTransport(from: root)
let desiredMode: ConnectionMode? = switch modeRaw { let desiredMode: ConnectionMode? = switch modeRaw {
case "local": case "local":
@@ -378,8 +396,17 @@ final class AppState {
self.connectionMode = .remote self.connectionMode = .remote
} }
if remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
let targetMode = desiredMode ?? self.connectionMode let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote, if targetMode == .remote,
remoteTransport != .direct,
let host = AppState.remoteHost(from: remoteUrl) let host = AppState.remoteHost(from: remoteUrl)
{ {
self.updateRemoteTarget(host: host) self.updateRemoteTarget(host: host)
@@ -402,6 +429,8 @@ final class AppState {
let connectionMode = self.connectionMode let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let desiredMode: String? = switch connectionMode { let desiredMode: String? = switch connectionMode {
case .local: case .local:
"local" "local"
@@ -435,39 +464,60 @@ final class AppState {
var remote = gateway["remote"] as? [String: Any] ?? [:] var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false var remoteChanged = false
if let host = remoteHost { if remoteTransport == .direct {
let existingUrl = (remote["url"] as? String)? let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmedUrl.isEmpty {
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) if remote["url"] != nil {
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" remote.removeValue(forKey: "url")
let port = parsedExisting?.port ?? 18789 remoteChanged = true
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" }
if existingUrl != desiredUrl { } else if (remote["url"] as? String) != trimmedUrl {
remote["url"] = desiredUrl remote["url"] = trimmedUrl
remoteChanged = true remoteChanged = true
} }
} if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
remote["transport"] = RemoteTransport.direct.rawValue
remoteChanged = true
}
} else {
if remote["transport"] != nil {
remote.removeValue(forKey: "transport")
remoteChanged = true
}
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty { if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget { if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true remoteChanged = true
} }
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty { if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity { if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true remoteChanged = true
} }
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
} }
if remoteChanged { if remoteChanged {
@@ -486,6 +536,17 @@ final class AppState {
} }
} }
private static func remoteTransport(from root: [String: Any]) -> RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let raw = remote["transport"] as? String
else {
return .ssh
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed == RemoteTransport.direct.rawValue ? .direct : .ssh
}
func triggerVoiceEars(ttl: TimeInterval? = 5) { func triggerVoiceEars(ttl: TimeInterval? = 5) {
self.earBoostTask?.cancel() self.earBoostTask?.cancel()
self.earBoostActive = true self.earBoostActive = true
@@ -621,8 +682,10 @@ extension AppState {
state.iconOverride = .system state.iconOverride = .system
state.heartbeatsEnabled = true state.heartbeatsEnabled = true
state.connectionMode = .local state.connectionMode = .local
state.remoteTransport = .ssh
state.canvasEnabled = true state.canvasEnabled = true
state.remoteTarget = "user@example.com" state.remoteTarget = "user@example.com"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdbot" state.remoteProjectRoot = "~/Projects/clawdbot"
state.remoteCliPath = "" state.remoteCliPath = ""

View File

@@ -0,0 +1,47 @@
import ClawdbotDiscovery
import Foundation
enum GatewayDiscoveryHelpers {
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
}
return target
}
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
tailnetDns: gateway.tailnetDns,
lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort)
}
static func directGatewayUrl(
tailnetDns: String?,
lanHost: String?,
gatewayPort: Int?) -> String?
{
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
return "wss://\(tailnetDns)"
}
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)"
}
static func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
return nil
}
return host
}
private static func trimmed(_ value: String?) -> String? {
value?.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -4,6 +4,8 @@ import SwiftUI
struct GatewayDiscoveryInlineList: View { struct GatewayDiscoveryInlineList: View {
var discovery: GatewayDiscoveryModel var discovery: GatewayDiscoveryModel
var currentTarget: String? var currentTarget: String?
var currentUrl: String?
var transport: AppState.RemoteTransport
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID? @State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
} else { } else {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ForEach(self.discovery.gateways.prefix(6)) { gateway in ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway) let display = self.displayInfo(for: gateway)
let selected = (target != nil && self.currentTarget? let selected = display.selected
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
Button { Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
Text(target ?? "Gateway pairing only") Text(display.label)
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor))) .fill(Color(NSColor.controlBackgroundColor)))
} }
} }
.help("Click a discovered gateway to fill the SSH target.") .help(self.transport == .direct
? "Click a discovered gateway to fill the gateway URL."
: "Click a discovered gateway to fill the SSH target.")
} }
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { private func displayInfo(
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
guard let host else { return nil } {
let user = NSUserName() switch self.transport {
return GatewayDiscoveryModel.buildSSHTarget( case .direct:
user: user, let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
host: host, let label = url ?? "Gateway pairing only"
port: gateway.sshPort) let selected = url != nil && self.trimmed(self.currentUrl) == url
} return (label, selected)
case .ssh:
private func sanitizedTailnetHost(_ host: String?) -> String? { let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
guard let host else { return nil } let label = target ?? "Gateway pairing only"
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) let selected = target != nil && self.trimmed(self.currentTarget) == target
if trimmed.isEmpty { return nil } return (label, selected)
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
return nil
} }
return trimmed
} }
private func rowBackground(selected: Bool, hovered: Bool) -> Color { private func rowBackground(selected: Bool, hovered: Bool) -> Color {
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
if hovered { return Color.secondary.opacity(0.08) } if hovered { return Color.secondary.opacity(0.08) }
return Color.clear return Color.clear
} }
private func trimmed(_ value: String?) -> String {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
} }
struct GatewayDiscoveryMenu: View { struct GatewayDiscoveryMenu: View {

View File

@@ -311,6 +311,19 @@ actor GatewayEndpointStore {
token: token, token: token,
password: password)) password: password))
case .remote: case .remote:
let root = ClawdbotConfigFile.loadDict()
if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" {
guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else {
self.cancelRemoteEnsure()
self.setState(.unavailable(
mode: .remote,
reason: "gateway.remote.url missing or invalid for direct transport"))
return
}
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return
}
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
@@ -341,6 +354,24 @@ actor GatewayEndpointStore {
code: 1, code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
} }
let root = ClawdbotConfigFile.loadDict()
if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" {
guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
let port = url.port ?? (url.scheme?.lowercased() == "wss" ? 443 : 80)
guard let portInt = UInt16(exactly: port) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
}
self.logger.info("remote transport direct; skipping SSH tunnel")
return portInt
}
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
throw NSError( throw NSError(
@@ -401,6 +432,21 @@ actor GatewayEndpointStore {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
} }
let root = ClawdbotConfigFile.loadDict()
if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" {
guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
let token = self.deps.token()
let password = self.deps.password()
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
}
self.kickRemoteEnsureIfNeeded(detail: detail) self.kickRemoteEnsureIfNeeded(detail: detail)
guard let ensure = self.remoteEnsure else { guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
@@ -535,6 +581,31 @@ actor GatewayEndpointStore {
return nil return nil
} }
private static func resolveRemoteTransport(root: [String: Any]) -> String {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let transportRaw = remote["transport"] as? String
else {
return "ssh"
}
let trimmed = transportRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed == "direct" ? "direct" : "ssh"
}
private static func resolveRemoteGatewayUrl(root: [String: Any]) -> URL? {
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) else { return nil }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return nil }
return url
}
private static func resolveGatewayScheme( private static func resolveGatewayScheme(
root: [String: Any], root: [String: Any],
env: [String: String]) -> String env: [String: String]) -> String

View File

@@ -17,6 +17,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
private var remoteLabelWidth: CGFloat { 88 }
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
@@ -104,7 +105,7 @@ struct GeneralSettings: View {
Picker("Mode", selection: self.$state.connectionMode) { Picker("Mode", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured) Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local) Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote) Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
} }
.pickerStyle(.menu) .pickerStyle(.menu)
.labelsHidden() .labelsHidden()
@@ -136,60 +137,51 @@ struct GeneralSettings: View {
private var remoteCard: some View { private var remoteCard: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) { self.remoteTransportRow
Text("SSH")
.font(.callout.weight(.semibold)) if self.state.remoteTransport == .ssh {
.frame(width: 48, alignment: .leading) self.remoteSshRow
TextField("user@host[:22]", text: self.$state.remoteTarget) } else {
.textFieldStyle(.roundedBorder) self.remoteDirectRow
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
GatewayDiscoveryInlineList( GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery, discovery: self.gatewayDiscovery,
currentTarget: self.state.remoteTarget) currentTarget: self.state.remoteTarget,
currentUrl: self.state.remoteUrl,
transport: self.state.remoteTransport)
{ gateway in { gateway in
self.applyDiscoveredGateway(gateway) self.applyDiscoveredGateway(gateway)
} }
.padding(.leading, 58) .padding(.leading, self.remoteLabelWidth + 10)
self.remoteStatusView self.remoteStatusView
.padding(.leading, 58) .padding(.leading, self.remoteLabelWidth + 10)
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { if self.state.remoteTransport == .ssh {
VStack(alignment: .leading, spacing: 8) { DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
LabeledContent("Identity file") { VStack(alignment: .leading, spacing: 8) {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) LabeledContent("Identity file") {
.textFieldStyle(.roundedBorder) TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.frame(width: 280) .textFieldStyle(.roundedBorder)
} .frame(width: 280)
LabeledContent("Project root") { }
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) LabeledContent("Project root") {
.textFieldStyle(.roundedBorder) TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.frame(width: 280) .textFieldStyle(.roundedBorder)
} .frame(width: 280)
LabeledContent("CLI path") { }
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath) LabeledContent("CLI path") {
.textFieldStyle(.roundedBorder) TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
.frame(width: 280) .textFieldStyle(.roundedBorder)
.frame(width: 280)
}
} }
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
} }
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
} }
// Diagnostics // Diagnostics
@@ -219,16 +211,89 @@ struct GeneralSettings: View {
} }
} }
Text("Tip: enable Tailscale for stable remote access.") if self.state.remoteTransport == .ssh {
.font(.footnote) Text("Tip: enable Tailscale for stable remote access.")
.foregroundStyle(.secondary) .font(.footnote)
.lineLimit(1) .foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
} }
.transition(.opacity) .transition(.opacity)
.onAppear { self.gatewayDiscovery.start() } .onAppear { self.gatewayDiscovery.start() }
.onDisappear { self.gatewayDiscovery.stop() } .onDisappear { self.gatewayDiscovery.stop() }
} }
private var remoteTransportRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(maxWidth: 320)
}
}
private var remoteSshRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private var remoteDirectRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 10) {
Text("Gateway")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
}
}
private var controlStatusLine: String { private var controlStatusLine: String {
switch ControlChannel.shared.state { switch ControlChannel.shared.state {
case .connected: "Connected" case .connected: "Connected"
@@ -458,24 +523,36 @@ extension GeneralSettings {
func testRemote() async { func testRemote() async {
self.remoteStatus = .checking self.remoteStatus = .checking
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
guard !settings.target.isEmpty else { if self.state.remoteTransport == .direct {
self.remoteStatus = .failed("Set an SSH target first") let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
return guard !trimmedUrl.isEmpty else {
self.remoteStatus = .failed("Set a gateway URL first")
return
}
guard Self.isValidWsUrl(trimmedUrl) else {
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
return
}
} else {
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
}
// Step 1: basic SSH reachability check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
} }
// Step 1: basic SSH reachability check // Step 2: control channel health check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
// Step 2: control channel health over tunnel
let originalMode = AppStateStore.shared.connectionMode let originalMode = AppStateStore.shared.connectionMode
do { do {
try await ControlChannel.shared.configure(mode: .remote( try await ControlChannel.shared.configure(mode: .remote(
@@ -502,6 +579,14 @@ extension GeneralSettings {
} }
} }
private static func isValidWsUrl(_ raw: String) -> Bool {
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return false }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !host.isEmpty
}
private static func sshCheckCommand(target: String, identity: String) -> [String] { private static func sshCheckCommand(target: String, identity: String) -> [String] {
var args: [String] = [ var args: [String] = [
"/usr/bin/ssh", "/usr/bin/ssh",
@@ -570,12 +655,18 @@ extension GeneralSettings {
let host = gateway.tailnetDns ?? gateway.lanHost let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return } guard let host else { return }
let user = NSUserName() let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( if self.state.remoteTransport == .direct {
user: user, if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
host: host, self.state.remoteUrl = url
port: gateway.sshPort) }
self.state.remoteCliPath = gateway.cliPath ?? "" } else {
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort) self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
} }
} }
@@ -598,7 +689,9 @@ extension GeneralSettings {
static func exerciseForTesting() { static func exerciseForTesting() {
let state = AppState(preview: true) let state = AppState(preview: true)
state.connectionMode = .remote state.connectionMode = .remote
state.remoteTransport = .ssh
state.remoteTarget = "user@host:2222" state.remoteTarget = "user@host:2222"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "/tmp/id_ed25519" state.remoteIdentity = "/tmp/id_ed25519"
state.remoteProjectRoot = "/tmp/clawdbot" state.remoteProjectRoot = "/tmp/clawdbot"
state.remoteCliPath = "/tmp/clawdbot" state.remoteCliPath = "/tmp/clawdbot"

View File

@@ -1,4 +1,5 @@
import AppKit import AppKit
import Foundation
import Observation import Observation
import SwiftUI import SwiftUI
@@ -517,11 +518,25 @@ extension MenuSessionsInjector {
switch mode { switch mode {
case .remote: case .remote:
platform = "remote" platform = "remote"
let target = AppStateStore.shared.remoteTarget if AppStateStore.shared.remoteTransport == .direct {
if let parsed = CommandResolver.parseSSHTarget(target) { let trimmedUrl = AppStateStore.shared.remoteUrl
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)" .trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
if let port = url.port {
host = "\(urlHost):\(port)"
} else {
host = urlHost
}
} else {
host = trimmedUrl.nonEmpty
}
} else { } else {
host = target.nonEmpty let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
} else {
host = target.nonEmpty
}
} }
case .local: case .local:
platform = "local" platform = "local"

View File

@@ -25,7 +25,11 @@ extension OnboardingView {
self.preferredGatewayID = gateway.stableID self.preferredGatewayID = gateway.stableID
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if let host = gateway.tailnetDns ?? gateway.lanHost { if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let user = NSUserName() let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user, user: user,

View File

@@ -177,42 +177,67 @@ extension OnboardingView {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow { GridRow {
Text("SSH target") Text("Transport")
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading) .frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget) Picker("Transport", selection: self.$state.remoteTransport) {
.textFieldStyle(.roundedBorder) Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
.frame(width: fieldWidth) Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(width: fieldWidth)
} }
GridRow { if self.state.remoteTransport == .direct {
Text("Identity file") GridRow {
.font(.callout.weight(.semibold)) Text("Gateway URL")
.frame(width: labelWidth, alignment: .leading) .font(.callout.weight(.semibold))
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) .frame(width: labelWidth, alignment: .leading)
.textFieldStyle(.roundedBorder) TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.frame(width: fieldWidth) .textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
} }
GridRow { if self.state.remoteTransport == .ssh {
Text("Project root") GridRow {
.font(.callout.weight(.semibold)) Text("SSH target")
.frame(width: labelWidth, alignment: .leading) .font(.callout.weight(.semibold))
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) .frame(width: labelWidth, alignment: .leading)
.textFieldStyle(.roundedBorder) TextField("user@host[:port]", text: self.$state.remoteTarget)
.frame(width: fieldWidth) .textFieldStyle(.roundedBorder)
} .frame(width: fieldWidth)
GridRow { }
Text("CLI path") GridRow {
.font(.callout.weight(.semibold)) Text("Identity file")
.frame(width: labelWidth, alignment: .leading) .font(.callout.weight(.semibold))
TextField( .frame(width: labelWidth, alignment: .leading)
"/Applications/Clawdbot.app/.../clawdbot", TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
text: self.$state.remoteCliPath) .textFieldStyle(.roundedBorder)
.textFieldStyle(.roundedBorder) .frame(width: fieldWidth)
.frame(width: fieldWidth) }
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/Clawdbot.app/.../clawdbot",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
} }
} }
Text("Tip: keep Tailscale enabled so your gateway stays reachable.") Text(self.state.remoteTransport == .direct
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -225,7 +250,10 @@ extension OnboardingView {
} }
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let host = gateway.tailnetDns ?? gateway.lanHost { if self.state.remoteTransport == .direct {
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
}
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : "" let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
return "\(host)\(portSuffix)" return "\(host)\(portSuffix)"
} }

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
describe("gateway.remote.transport", () => {
it("accepts direct transport", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: {
remote: {
transport: "direct",
url: "wss://gateway.example.ts.net",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects unknown transport", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: {
remote: {
transport: "udp",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("gateway.remote.transport");
}
});
});

View File

@@ -80,6 +80,8 @@ export type GatewayTailscaleConfig = {
export type GatewayRemoteConfig = { export type GatewayRemoteConfig = {
/** Remote Gateway WebSocket URL (ws:// or wss://). */ /** Remote Gateway WebSocket URL (ws:// or wss://). */
url?: string; url?: string;
/** Transport for macOS remote connections (ssh tunnel or direct WS). */
transport?: "ssh" | "direct";
/** Token for remote auth (when the gateway requires token auth). */ /** Token for remote auth (when the gateway requires token auth). */
token?: string; token?: string;
/** Password for remote auth (when the gateway requires password auth). */ /** Password for remote auth (when the gateway requires password auth). */

View File

@@ -334,6 +334,7 @@ export const ClawdbotSchema = z
remote: z remote: z
.object({ .object({
url: z.string().optional(), url: z.string().optional(),
transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
token: z.string().optional(), token: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
tlsFingerprint: z.string().optional(), tlsFingerprint: z.string().optional(),