Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a482845031 | ||
|
|
7481086d74 |
No files matched your search
+1
-1
@@ -12,8 +12,8 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- 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.
|
||||||
|
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||||
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ 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) } }
|
||||||
}
|
}
|
||||||
@@ -171,10 +166,6 @@ 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) } }
|
||||||
}
|
}
|
||||||
@@ -209,10 +200,6 @@ 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) } }
|
||||||
}
|
}
|
||||||
@@ -276,15 +263,13 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let configRoot = ClawdbotConfigFile.loadDict()
|
let configRoot = ClawdbotConfigFile.loadDict()
|
||||||
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||||
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -292,7 +277,6 @@ 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) ?? ""
|
||||||
@@ -370,11 +354,10 @@ 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 = GatewayRemoteConfig.resolveUrlString(root: root)
|
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
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":
|
||||||
@@ -395,17 +378,8 @@ 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)
|
||||||
@@ -428,8 +402,6 @@ 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"
|
||||||
@@ -463,63 +435,39 @@ final class AppState {
|
|||||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
var remoteChanged = false
|
var remoteChanged = false
|
||||||
|
|
||||||
if remoteTransport == .direct {
|
if let host = remoteHost {
|
||||||
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
let existingUrl = (remote["url"] as? String)?
|
||||||
if trimmedUrl.isEmpty {
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if remote["url"] != nil {
|
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||||
remote.removeValue(forKey: "url")
|
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||||
remoteChanged = true
|
let port = parsedExisting?.port ?? 18789
|
||||||
}
|
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||||
} else {
|
if existingUrl != desiredUrl {
|
||||||
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
remote["url"] = desiredUrl
|
||||||
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 {
|
||||||
@@ -673,10 +621,8 @@ 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 = ""
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,6 @@ 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?
|
||||||
|
|
||||||
@@ -27,8 +25,9 @@ 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 display = self.displayInfo(for: gateway)
|
let target = self.suggestedSSHTarget(gateway)
|
||||||
let selected = display.selected
|
let selected = (target != nil && self.currentTarget?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
@@ -41,7 +40,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
Text(display.label)
|
Text(target ?? "Gateway pairing only")
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -84,26 +83,27 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.fill(Color(NSColor.controlBackgroundColor)))
|
.fill(Color(NSColor.controlBackgroundColor)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.help(self.transport == .direct
|
.help("Click a discovered gateway to fill the SSH target.")
|
||||||
? "Click a discovered gateway to fill the gateway URL."
|
|
||||||
: "Click a discovered gateway to fill the SSH target.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func displayInfo(
|
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||||
{
|
guard let host else { return nil }
|
||||||
switch self.transport {
|
let user = NSUserName()
|
||||||
case .direct:
|
return GatewayDiscoveryModel.buildSSHTarget(
|
||||||
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
user: user,
|
||||||
let label = url ?? "Gateway pairing only"
|
host: host,
|
||||||
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
port: gateway.sshPort)
|
||||||
return (label, selected)
|
}
|
||||||
case .ssh:
|
|
||||||
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||||
let label = target ?? "Gateway pairing only"
|
guard let host else { return nil }
|
||||||
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return (label, selected)
|
if trimmed.isEmpty { return nil }
|
||||||
|
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,10 +111,6 @@ 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 {
|
||||||
|
|||||||
@@ -311,19 +311,6 @@ 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))
|
||||||
@@ -354,25 +341,6 @@ 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(
|
||||||
@@ -433,21 +401,6 @@ 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…"])
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ 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) {
|
||||||
@@ -105,7 +104,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 (another host)").tag(AppState.ConnectionMode.remote)
|
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@@ -137,51 +136,60 @@ struct GeneralSettings: View {
|
|||||||
|
|
||||||
private var remoteCard: some View {
|
private var remoteCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
self.remoteTransportRow
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text("SSH")
|
||||||
if self.state.remoteTransport == .ssh {
|
.font(.callout.weight(.semibold))
|
||||||
self.remoteSshRow
|
.frame(width: 48, alignment: .leading)
|
||||||
} else {
|
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||||
self.remoteDirectRow
|
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, self.remoteLabelWidth + 10)
|
.padding(.leading, 58)
|
||||||
|
|
||||||
self.remoteStatusView
|
self.remoteStatusView
|
||||||
.padding(.leading, self.remoteLabelWidth + 10)
|
.padding(.leading, 58)
|
||||||
|
|
||||||
if self.state.remoteTransport == .ssh {
|
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
LabeledContent("Identity file") {
|
||||||
LabeledContent("Identity file") {
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: 280)
|
||||||
.frame(width: 280)
|
}
|
||||||
}
|
LabeledContent("Project root") {
|
||||||
LabeledContent("Project root") {
|
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: 280)
|
||||||
.frame(width: 280)
|
}
|
||||||
}
|
LabeledContent("CLI path") {
|
||||||
LabeledContent("CLI path") {
|
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: 280)
|
||||||
.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
|
||||||
@@ -211,89 +219,16 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.state.remoteTransport == .ssh {
|
Text("Tip: enable Tailscale for stable remote access.")
|
||||||
Text("Tip: enable Tailscale for stable remote access.")
|
.font(.footnote)
|
||||||
.font(.footnote)
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
.lineLimit(1)
|
||||||
.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"
|
||||||
@@ -523,36 +458,24 @@ extension GeneralSettings {
|
|||||||
func testRemote() async {
|
func testRemote() async {
|
||||||
self.remoteStatus = .checking
|
self.remoteStatus = .checking
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
if self.state.remoteTransport == .direct {
|
guard !settings.target.isEmpty else {
|
||||||
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.remoteStatus = .failed("Set an SSH target first")
|
||||||
guard !trimmedUrl.isEmpty else {
|
return
|
||||||
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 2: control channel health check
|
// 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 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(
|
||||||
@@ -579,14 +502,6 @@ 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",
|
||||||
@@ -655,18 +570,12 @@ 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()
|
||||||
if self.state.remoteTransport == .direct {
|
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
user: user,
|
||||||
self.state.remoteUrl = url
|
host: host,
|
||||||
}
|
port: gateway.sshPort)
|
||||||
} else {
|
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||||
user: user,
|
|
||||||
host: host,
|
|
||||||
port: gateway.sshPort)
|
|
||||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
|
||||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,9 +598,7 @@ 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"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -518,25 +517,11 @@ extension MenuSessionsInjector {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .remote:
|
case .remote:
|
||||||
platform = "remote"
|
platform = "remote"
|
||||||
if AppStateStore.shared.remoteTransport == .direct {
|
let target = AppStateStore.shared.remoteTarget
|
||||||
let trimmedUrl = AppStateStore.shared.remoteUrl
|
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||||
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 {
|
||||||
let target = AppStateStore.shared.remoteTarget
|
host = target.nonEmpty
|
||||||
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"
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ extension OnboardingView {
|
|||||||
self.preferredGatewayID = gateway.stableID
|
self.preferredGatewayID = gateway.stableID
|
||||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||||
|
|
||||||
if self.state.remoteTransport == .direct {
|
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||||
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,
|
||||||
|
|||||||
@@ -177,67 +177,42 @@ 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("Transport")
|
Text("SSH target")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
.textFieldStyle(.roundedBorder)
|
||||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
.frame(width: fieldWidth)
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
}
|
||||||
if self.state.remoteTransport == .direct {
|
GridRow {
|
||||||
GridRow {
|
Text("Identity file")
|
||||||
Text("Gateway URL")
|
.font(.callout.weight(.semibold))
|
||||||
.font(.callout.weight(.semibold))
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: fieldWidth)
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if self.state.remoteTransport == .ssh {
|
GridRow {
|
||||||
GridRow {
|
Text("Project root")
|
||||||
Text("SSH target")
|
.font(.callout.weight(.semibold))
|
||||||
.font(.callout.weight(.semibold))
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.frame(width: fieldWidth)
|
||||||
.frame(width: fieldWidth)
|
}
|
||||||
}
|
GridRow {
|
||||||
GridRow {
|
Text("CLI path")
|
||||||
Text("Identity file")
|
.font(.callout.weight(.semibold))
|
||||||
.font(.callout.weight(.semibold))
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
TextField(
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
"/Applications/Clawdbot.app/.../clawdbot",
|
||||||
.textFieldStyle(.roundedBorder)
|
text: self.$state.remoteCliPath)
|
||||||
.frame(width: fieldWidth)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
.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(self.state.remoteTransport == .direct
|
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||||
? "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)
|
||||||
@@ -250,10 +225,7 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
if self.state.remoteTransport == .direct {
|
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||||
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)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,10 +175,4 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2825,14 +2825,13 @@ 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` + `gateway.remote.transport` 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` in remote mode) back to the config file.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -2847,21 +2846,6 @@ 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.
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ read_when:
|
|||||||
# Elevated Mode (/elevated directives)
|
# Elevated Mode (/elevated directives)
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
|
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
|
||||||
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
|
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
|
||||||
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
|
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
|
||||||
|
- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
|
||||||
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
||||||
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
|
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
|
||||||
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
|
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
|
||||||
@@ -18,8 +19,8 @@ read_when:
|
|||||||
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
|
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
|
||||||
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
|
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
|
||||||
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
||||||
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
|
||||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
|
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
|
||||||
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
||||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
|
|||||||
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
|
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
|
||||||
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
||||||
- `node` (string): node id/name for `host=node`
|
- `node` (string): node id/name for `host=node`
|
||||||
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
|
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `host` defaults to `sandbox`.
|
- `host` defaults to `sandbox`.
|
||||||
|
|||||||
@@ -150,4 +150,35 @@ describe("exec approvals", () => {
|
|||||||
expect(result.details.status).toBe("completed");
|
expect(result.details.status).toBe("completed");
|
||||||
expect(calls).not.toContain("exec.approval.request");
|
expect(calls).not.toContain("exec.approval.request");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires approval for elevated ask when allowlist misses", async () => {
|
||||||
|
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||||
|
const calls: string[] = [];
|
||||||
|
let resolveApproval: (() => void) | undefined;
|
||||||
|
const approvalSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveApproval = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||||
|
calls.push(method);
|
||||||
|
if (method === "exec.approval.request") {
|
||||||
|
resolveApproval?.();
|
||||||
|
return { decision: "deny" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||||
|
const tool = createExecTool({
|
||||||
|
ask: "on-miss",
|
||||||
|
security: "allowlist",
|
||||||
|
approvalRunningNoticeMs: 0,
|
||||||
|
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
|
||||||
|
expect(result.details.status).toBe("approval-pending");
|
||||||
|
await approvalSeen;
|
||||||
|
expect(calls).toContain("exec.approval.request");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -791,7 +791,7 @@ export function createExecTool(
|
|||||||
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
|
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
|
||||||
const requestedSecurity = normalizeExecSecurity(params.security);
|
const requestedSecurity = normalizeExecSecurity(params.security);
|
||||||
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
||||||
if (elevatedRequested) {
|
if (elevatedRequested && elevatedMode === "full") {
|
||||||
security = "full";
|
security = "full";
|
||||||
}
|
}
|
||||||
const configuredAsk = defaults?.ask ?? "on-miss";
|
const configuredAsk = defaults?.ask ?? "on-miss";
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -80,8 +80,6 @@ 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). */
|
||||||
|
|||||||
@@ -332,7 +332,6 @@ 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(),
|
||||||
|
|||||||
Reference in new issue
Block a user