Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
c4e4e54055 fix: default direct gateway port + docs (#1603) (thanks @ngutman) 2026-01-24 21:01:42 +00:00
Nimrod Gutman
a8a3c85b4c feat(macos): add direct gateway transport 2026-01-24 20:54:04 +00:00
15 changed files with 570 additions and 155 deletions

View File

@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot
- Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing. - Heartbeat: normalize target identifiers for consistent routing.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
## 2026.1.23-1 ## 2026.1.23-1

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) } }
} }
@ -263,13 +276,15 @@ final class AppState {
} }
let configRoot = ClawdbotConfigFile.loadDict() let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any] let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: 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 +292,7 @@ final class AppState {
} else { } else {
self.remoteTarget = storedRemoteTarget self.remoteTarget = storedRemoteTarget
} }
self.remoteUrl = configRemoteUrl ?? ""
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) ?? ""
@ -354,10 +370,11 @@ final class AppState {
private func applyConfigOverrides(_ root: [String: Any]) { private func applyConfigOverrides(_ root: [String: Any]) {
let gateway = root["gateway"] as? [String: Any] let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let hasRemoteUrl = !(remoteUrl? let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
let desiredMode: ConnectionMode? = switch modeRaw { let desiredMode: ConnectionMode? = switch modeRaw {
case "local": case "local":
@ -378,8 +395,17 @@ final class AppState {
self.connectionMode = .remote self.connectionMode = .remote
} }
if remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteUrl ?? ""
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 +428,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 +463,63 @@ 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 {
remote["url"] = desiredUrl let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
if (remote["url"] as? String) != normalizedUrl {
remote["url"] = normalizedUrl
remoteChanged = true
}
}
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
remote["transport"] = RemoteTransport.direct.rawValue
remoteChanged = true 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 {
@ -621,8 +673,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 GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(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,25 @@ 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 GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
guard let port = GatewayRemoteConfig.defaultPort(for: url),
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 +433,21 @@ actor GatewayEndpointStore {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
} }
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(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…"])

View File

@ -0,0 +1,64 @@
import Foundation
enum GatewayRemoteConfig {
static func resolveTransport(root: [String: Any]) -> AppState.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 == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
}
static func resolveUrlString(root: [String: Any]) -> String? {
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)
return trimmed.isEmpty ? nil : trimmed
}
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
guard let raw = self.resolveUrlString(root: root) else { return nil }
return self.normalizeGatewayUrl(raw)
}
static func normalizeGatewayUrlString(_ raw: String) -> String? {
self.normalizeGatewayUrl(raw)?.absoluteString
}
static func normalizeGatewayUrl(_ raw: String) -> URL? {
let trimmed = raw.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 }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty else { return nil }
if scheme == "ws", url.port == nil {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
components.port = 18789
return components.url
}
return url
}
static func defaultPort(for url: URL) -> Int? {
if let port = url.port { return port }
let scheme = url.scheme?.lowercased() ?? ""
switch scheme {
case "wss":
return 443
case "ws":
return 18789
default:
return nil
}
}
}

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

@ -175,4 +175,10 @@ import Testing
customBindHost: "192.168.1.10") customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10") #expect(host == "192.168.1.10")
} }
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
#expect(url?.port == 18789)
#expect(url?.absoluteString == "ws://gateway:18789")
}
} }

View File

@ -2825,13 +2825,14 @@ Auth and Tailscale:
Remote client defaults (CLI): Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`. - `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth). - `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth). - `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
macOS app behavior: macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes. - Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode. - If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file. - When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` + `gateway.remote.transport` in remote mode) back to the config file.
```json5 ```json5
{ {
@ -2846,6 +2847,21 @@ macOS app behavior:
} }
``` ```
Direct transport example (macOS app):
```json5
{
gateway: {
mode: "remote",
remote: {
transport: "direct",
url: "wss://gateway.example.ts.net",
token: "your-token"
}
}
}
```
### `gateway.reload` (Config hot reload) ### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.

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

@ -332,6 +332,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(),