diff --git a/CHANGELOG.md b/CHANGELOG.md index 7201b58a72e..ae425227e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai - CLI/status: label the OpenClaw Serve/Funnel setting as `Tailscale exposure` and show daemon state separately when available, so `gateway.tailscale.mode: "off"` no longer reads like the Tailscale daemon is stopped. Fixes #71790. Thanks @pesvobodak. - Plugins/Bonjour: stop ciao mDNS watchdog failures from looping forever when the advertiser stays stuck in `probing` or `announcing`; Bonjour now disables itself for the current Gateway process after repeated failed restarts while the Gateway keeps running. Fixes #69011. Thanks @siddharthaagarwalofficial-ux, @FiredMosquito831, and @spikefcz. - Gateway/Fly.io: seed Control UI allowed origins from the actual runtime bind and port so CLI-driven non-loopback starts do not crash before config exists. Fixes #71823. +- macOS/remote SSH: keep discovered gateway hosts in `gateway.remote.sshTarget` while pinning SSH transport URLs to the local loopback tunnel, so browser automation does not regress into blocked non-loopback `ws://` endpoints. Fixes #67336. - Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` before the first embedded agent attempt runs. (#71833) Thanks @mjamiv. - Plugins/runtime deps: verify clean npm installs actually place requested bundled runtime packages in the managed install root, reporting exact missing specs instead of a false successful repair. (#71883) Thanks @Solvely-Colin. - Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path. Thanks @codex. diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 9e008682482..bcdc392e23c 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import Observation +import OpenClawKit import ServiceManagement import SwiftUI @@ -366,7 +367,8 @@ final class AppState { if resolvedConnectionMode == .remote, configRemoteTransport != .direct, storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let host = AppState.remoteHost(from: configRemoteUrl) + let host = AppState.remoteHost(from: configRemoteUrl), + !LoopbackHost.isLoopbackHost(host) { self.remoteTarget = "\(NSUserName())@\(host)" } else { @@ -435,6 +437,32 @@ final class AppState { return trimmed } + private static func sshTunnelGatewayUrl(existingUrl: String?, expectedRemoteHost: String?) -> String { + let fallback = "ws://127.0.0.1:18789" + let trimmed = existingUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty, + let url = URL(string: trimmed), + let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty + else { + return fallback + } + + let preservePort: Bool + if LoopbackHost.isLoopbackHost(host) { + preservePort = true + } else if let expectedRemoteHost { + preservePort = + OpenClawConfigFile.canonicalHostForComparison(host) == + OpenClawConfigFile.canonicalHostForComparison(expectedRemoteHost) + } else { + preservePort = false + } + guard preservePort else { return fallback } + + return "ws://127.0.0.1:\(url.port ?? 18789)" + } + private static func updateGatewayString( _ dictionary: inout [String: Any], key: String, @@ -491,17 +519,14 @@ final class AppState { case .ssh: changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed - if let host = draft.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)" - changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed - } - let sanitizedTarget = Self.sanitizeSSHTarget(draft.remoteTarget) + let expectedRemoteHost = CommandResolver.parseSSHTarget(sanitizedTarget)?.host ?? draft.remoteHost + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let desiredUrl = Self.sshTunnelGatewayUrl( + existingUrl: existingUrl, + expectedRemoteHost: expectedRemoteHost) + changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: draft.remoteIdentity) || changed } @@ -569,7 +594,8 @@ final class AppState { let targetMode = desiredMode ?? self.connectionMode if targetMode == .remote, remoteTransport != .direct, - let host = AppState.remoteHost(from: remoteUrl) + let host = AppState.remoteHost(from: remoteUrl), + !LoopbackHost.isLoopbackHost(host) { self.updateRemoteTarget(host: host) } diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift index 99bb654526b..860433c9788 100644 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift +++ b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift @@ -1,7 +1,11 @@ +import Foundation import OpenClawDiscovery +import OpenClawKit @MainActor enum GatewayDiscoverySelectionSupport { + private static let defaultSshTunnelGatewayUrl = "ws://127.0.0.1:18789" + static func applyRemoteSelection( gateway: GatewayDiscoveryModel.DiscoveredGateway, state: AppState) @@ -13,18 +17,40 @@ enum GatewayDiscoverySelectionSupport { state.remoteTransport = preferredTransport } - state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + if preferredTransport == .direct { + state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + } else { + state.remoteUrl = self.sshTunnelGatewayUrl(current: state.remoteUrl) + } state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" - if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { - OpenClawConfigFile.setRemoteGatewayUrl( - host: endpoint.host, - port: endpoint.port) + if preferredTransport == .direct { + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { + OpenClawConfigFile.setRemoteGatewayUrl( + host: endpoint.host, + port: endpoint.port) + } else { + OpenClawConfigFile.clearRemoteGatewayUrl() + } } else { - OpenClawConfigFile.clearRemoteGatewayUrl() + OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl) } } + private static func sshTunnelGatewayUrl(current: String) -> String { + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let url = URL(string: trimmed), + let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty, + LoopbackHost.isLoopbackHost(host) + else { + return self.defaultSshTunnelGatewayUrl + } + + return "ws://127.0.0.1:\(url.port ?? 18789)" + } + static func preferredTransport( for gateway: GatewayDiscoveryModel.DiscoveredGateway, current: AppState.RemoteTransport) -> AppState.RemoteTransport diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 5b320dbcd08..31a73d8c62f 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -192,20 +192,17 @@ enum OpenClawConfigFile { } static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { - let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedSshHost.isEmpty, + guard let normalizedSshHost = Self.canonicalHostForComparison(sshHost), let url = self.remoteGatewayUrl(), let port = url.port, port > 0, - let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), - !urlHost.isEmpty + let urlHost = url.host, + let normalizedUrlHost = Self.canonicalHostForComparison(urlHost) else { return nil } - let sshKey = Self.hostKey(trimmedSshHost) - let urlKey = Self.hostKey(urlHost) - guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil } + guard normalizedSshHost == normalizedUrlHost else { return nil } return port } @@ -223,6 +220,16 @@ enum OpenClawConfigFile { } } + static func setRemoteGatewayUrlString(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.updateGatewayDict { gateway in + var remote = gateway["remote"] as? [String: Any] ?? [:] + remote["url"] = trimmed + gateway["remote"] = remote + } + } + static func clearRemoteGatewayUrl() { self.updateGatewayDict { gateway in guard var remote = gateway["remote"] as? [String: Any] else { return } @@ -249,15 +256,17 @@ enum OpenClawConfigFile { return url } - static func hostKey(_ host: String) -> String { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return "" } - if trimmed.contains(":") { return trimmed } - let digits = CharacterSet(charactersIn: "0123456789.") - if trimmed.rangeOfCharacter(from: digits.inverted) == nil { - return trimmed + static func canonicalHostForComparison(_ raw: String?) -> String? { + guard var host = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !host.isEmpty + else { + return nil } - return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + host = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + while host.hasSuffix(".") { + host.removeLast() + } + return host.isEmpty ? nil : host } private static func parseConfigData(_ data: Data) -> [String: Any]? { diff --git a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift index 79af1d61828..d2e13ca8586 100644 --- a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift +++ b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift @@ -150,9 +150,11 @@ final class RemotePortTunnel { else { return nil } - let sshKey = OpenClawConfigFile.hostKey(sshHost) - let urlKey = OpenClawConfigFile.hostKey(host) - guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } + guard let sshKey = OpenClawConfigFile.canonicalHostForComparison(sshHost), + let urlKey = OpenClawConfigFile.canonicalHostForComparison(host) + else { + return nil + } guard sshKey == urlKey else { Self.logger.debug( "remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)") diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index d7f66e83d05..479e9ca2e42 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import OpenClaw @@ -36,6 +37,130 @@ struct AppStateRemoteConfigTests { #expect((remote["token"] as? String) == nil) } + @Test + func updatedRemoteGatewayConfigPinsLoopbackUrlForSshTransport() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: ["url": "ws://gateway.example:18789"], + draft: .init( + transport: .ssh, + remoteUrl: "", + remoteHost: "gateway.example", + remoteTarget: "alice@gateway.example", + remoteIdentity: "", + remoteToken: "", + remoteTokenDirty: false)) + + #expect(remote["url"] as? String == "ws://127.0.0.1:18789") + #expect((remote["transport"] as? String) == nil) + #expect(remote["sshTarget"] as? String == "alice@gateway.example") + } + + @Test + func updatedRemoteGatewayConfigPreservesCustomLoopbackTunnelPort() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: ["url": "ws://localhost.:29876"], + draft: .init( + transport: .ssh, + remoteUrl: "", + remoteHost: "gateway.example", + remoteTarget: "alice@gateway.example", + remoteIdentity: "", + remoteToken: "", + remoteTokenDirty: false)) + + #expect(remote["url"] as? String == "ws://127.0.0.1:29876") + } + + @Test + func updatedRemoteGatewayConfigPreservesCustomPortWhenExistingHostMatchesSshTarget() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: ["url": "ws://gateway.example:19999"], + draft: .init( + transport: .ssh, + remoteUrl: "", + remoteHost: nil, + remoteTarget: "alice@gateway.example", + remoteIdentity: "", + remoteToken: "", + remoteTokenDirty: false)) + + #expect(remote["url"] as? String == "ws://127.0.0.1:19999") + } + + @Test + func updatedRemoteGatewayConfigDropsCustomPortWhenExistingHostDoesNotMatchSshTarget() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: ["url": "ws://other-host.example:19999"], + draft: .init( + transport: .ssh, + remoteUrl: "", + remoteHost: "gateway.example", + remoteTarget: "alice@gateway.example", + remoteIdentity: "", + remoteToken: "", + remoteTokenDirty: false)) + + #expect(remote["url"] as? String == "ws://127.0.0.1:18789") + } + + @Test + func updatedRemoteGatewayConfigDoesNotPreservePortForHostnamePrefixCollision() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: ["url": "ws://example.attacker.tld:19999"], + draft: .init( + transport: .ssh, + remoteUrl: "", + remoteHost: nil, + remoteTarget: "alice@example.com", + remoteIdentity: "", + remoteToken: "", + remoteTokenDirty: false)) + + #expect(remote["url"] as? String == "ws://127.0.0.1:18789") + } + + @Test + func appStateInitDoesNotInferLoopbackHostIntoRemoteTarget() async { + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withIsolatedState( + env: ["OPENCLAW_CONFIG_PATH": configPath], + defaults: [remoteTargetKey: nil]) + { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "remote", + "remote": [ + "url": "ws://127.0.0.1:19999", + ], + ], + ]) + + let state = AppState(preview: true) + #expect(state.remoteTarget == "") + } + } + + @Test + func appStateInitPreservesExistingRemoteTargetWhenRemoteUrlIsLoopback() async { + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withIsolatedState( + env: ["OPENCLAW_CONFIG_PATH": configPath], + defaults: [remoteTargetKey: "alice@gateway.example"]) + { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "remote", + "remote": [ + "url": "ws://127.0.0.1:19999", + ], + ], + ]) + + let state = AppState(preview: true) + #expect(state.remoteTarget == "alice@gateway.example") + } + } + @Test func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() { let initialRoot: [String: Any] = [ diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift index fcfad8d9d85..2458ef3cf06 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift @@ -84,7 +84,35 @@ struct GatewayDiscoverySelectionSupportTests { state: state) #expect(state.remoteTransport == .ssh) + #expect(state.remoteUrl == "ws://127.0.0.1:18789") #expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local") + + let configRoot = OpenClawConfigFile.loadDict() + let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:] + #expect(remote["url"] as? String == "ws://127.0.0.1:18789") + } + } + + @Test func `selecting nearby lan gateway preserves existing ssh tunnel port`() async { + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteUrl = "ws://localhost:29876" + + GatewayDiscoverySelectionSupport.applyRemoteSelection( + gateway: self.makeGateway( + serviceHost: "nearby-gateway.local", + servicePort: 19999, + stableID: "bonjour|nearby-gateway-custom"), + state: state) + + #expect(state.remoteTransport == .ssh) + #expect(state.remoteUrl == "ws://127.0.0.1:29876") + + let configRoot = OpenClawConfigFile.loadDict() + let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:] + #expect(remote["url"] as? String == "ws://127.0.0.1:29876") } } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index 284e5fda1ee..ad559be0957 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -35,8 +35,30 @@ struct OpenClawConfigFileTests { ]) #expect(OpenClawConfigFile.remoteGatewayPort() == 19999) #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999) - #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == 19999) + #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "GATEWAY.ts.net.") == 19999) + #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == nil) #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil) + #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.attacker.tld") == nil) + } + } + + @MainActor + @Test + func `set remote gateway url string replaces scheme`() async { + let override = self.makeConfigOverridePath() + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "wss://old-host:111", + ], + ], + ]) + OpenClawConfigFile.setRemoteGatewayUrlString("ws://127.0.0.1:18789") + let root = OpenClawConfigFile.loadDict() + let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String + #expect(url == "ws://127.0.0.1:18789") } } diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index 49e86c18359..eed33e7d3f2 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -98,6 +98,8 @@ You can persist a remote target so CLI commands use it by default: ``` When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and open the SSH tunnel first. +In the macOS app’s SSH tunnel transport, discovered gateway hostnames belong in +`gateway.remote.sshTarget`; `gateway.remote.url` remains the local tunnel URL. ## Credential precedence diff --git a/docs/platforms/mac/remote.md b/docs/platforms/mac/remote.md index 2a0ab1b168e..1af309b8833 100644 --- a/docs/platforms/mac/remote.md +++ b/docs/platforms/mac/remote.md @@ -22,6 +22,11 @@ Remote mode supports two transports: - **SSH tunnel** (default): Uses `ssh -N -L ...` to forward the gateway port to localhost. The gateway will see the node’s IP as `127.0.0.1` because the tunnel is loopback. - **Direct (ws/wss)**: Connects straight to the gateway URL. The gateway sees the real client IP. +In SSH tunnel mode, discovered LAN/tailnet hostnames are saved as +`gateway.remote.sshTarget`. The app keeps `gateway.remote.url` on the local +tunnel endpoint, for example `ws://127.0.0.1:18789`, so CLI, Web Chat, and +browser automation all use the same safe loopback transport. + ## Prereqs on the remote host 1. Install Node + pnpm and build/install the OpenClaw CLI (`pnpm install && pnpm build && pnpm link --global`).