diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c0e8edf2..d773132b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Docs: https://docs.clawd.bot - Web UI: hide internal `message_id` hints in chat bubbles. - Heartbeat: normalize target identifiers for consistent routing. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. -- Tlon: fix Zod v4 record keys + aura v3 DM ids. (#1631) Thanks @arthyn. +- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. ## 2026.1.23-1 diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index b70b0afe0..eeaf034d0 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -276,9 +276,8 @@ final class AppState { } let configRoot = ClawdbotConfigFile.loadDict() - let configGateway = configRoot["gateway"] as? [String: Any] - let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String - let configRemoteTransport = AppState.remoteTransport(from: configRoot) + let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot) + let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot) let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode self.remoteTransport = configRemoteTransport self.connectionMode = resolvedConnectionMode @@ -293,7 +292,7 @@ final class AppState { } else { self.remoteTarget = storedRemoteTarget } - self.remoteUrl = configRemoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.remoteUrl = configRemoteUrl ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" @@ -371,11 +370,11 @@ final class AppState { private func applyConfigOverrides(_ root: [String: Any]) { let gateway = root["gateway"] as? [String: Any] 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? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) - let remoteTransport = AppState.remoteTransport(from: root) + let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root) let desiredMode: ConnectionMode? = switch modeRaw { case "local": @@ -399,7 +398,7 @@ final class AppState { if remoteTransport != self.remoteTransport { self.remoteTransport = remoteTransport } - let remoteUrlText = remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let remoteUrlText = remoteUrl ?? "" if remoteUrlText != self.remoteUrl { self.remoteUrl = remoteUrlText } @@ -471,9 +470,12 @@ final class AppState { remote.removeValue(forKey: "url") remoteChanged = true } - } else if (remote["url"] as? String) != trimmedUrl { - remote["url"] = trimmedUrl - remoteChanged = true + } else { + 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 @@ -536,17 +538,6 @@ final class AppState { } } - private static func remoteTransport(from root: [String: Any]) -> RemoteTransport { - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let raw = remote["transport"] as? String - else { - return .ssh - } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return trimmed == RemoteTransport.direct.rawValue ? .direct : .ssh - } - func triggerVoiceEars(ttl: TimeInterval? = 5) { self.earBoostTask?.cancel() self.earBoostActive = true diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index 043e4f5ae..d9385bc79 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -312,8 +312,8 @@ actor GatewayEndpointStore { password: password)) case .remote: let root = ClawdbotConfigFile.loadDict() - if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" { - guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else { + if GatewayRemoteConfig.resolveTransport(root: root) == .direct { + guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { self.cancelRemoteEnsure() self.setState(.unavailable( mode: .remote, @@ -355,15 +355,16 @@ actor GatewayEndpointStore { userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } let root = ClawdbotConfigFile.loadDict() - if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" { - guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else { + 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 port = url.port ?? (url.scheme?.lowercased() == "wss" ? 443 : 80) - guard let portInt = UInt16(exactly: port) else { + guard let port = GatewayRemoteConfig.defaultPort(for: url), + let portInt = UInt16(exactly: port) + else { throw NSError( domain: "GatewayEndpoint", code: 1, @@ -433,8 +434,8 @@ actor GatewayEndpointStore { } let root = ClawdbotConfigFile.loadDict() - if GatewayEndpointStore.resolveRemoteTransport(root: root) == "direct" { - guard let url = GatewayEndpointStore.resolveRemoteGatewayUrl(root: root) else { + if GatewayRemoteConfig.resolveTransport(root: root) == .direct { + guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { throw NSError( domain: "GatewayEndpoint", code: 1, @@ -581,31 +582,6 @@ actor GatewayEndpointStore { return nil } - private static func resolveRemoteTransport(root: [String: Any]) -> String { - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let transportRaw = remote["transport"] as? String - else { - return "ssh" - } - let trimmed = transportRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return trimmed == "direct" ? "direct" : "ssh" - } - - private static func resolveRemoteGatewayUrl(root: [String: Any]) -> URL? { - guard let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let urlRaw = remote["url"] as? String - else { - return nil - } - let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } - let scheme = url.scheme?.lowercased() ?? "" - guard scheme == "ws" || scheme == "wss" else { return nil } - return url - } - private static func resolveGatewayScheme( root: [String: Any], env: [String: String]) -> String diff --git a/apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift b/apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift new file mode 100644 index 000000000..0b8ab3515 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift @@ -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 + } + } +} diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift index 3513388a2..8cefe75e2 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift @@ -175,4 +175,10 @@ import Testing customBindHost: "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") + } } diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 507a1487a..f823b560f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2825,13 +2825,14 @@ Auth and Tailscale: Remote client defaults (CLI): - `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.password` supplies the password for remote calls (leave unset for no auth). macOS app behavior: - 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. -- 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 { @@ -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) The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.