diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e9bf3250dd..f78ee65ab6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3. - Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs. - Security/Browser relay: harden extension relay auth token handling for `/extension` and `/cdp` pathways. - Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario. diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift index 6d0259300b5..81383efa21a 100644 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift +++ b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift @@ -2,6 +2,17 @@ import Foundation import OpenClawDiscovery enum GatewayDiscoveryHelpers { + static func resolvedServiceHost( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? + { + self.resolvedServiceHost(gateway.serviceHost) + } + + static func resolvedServiceHost(_ host: String?) -> String? { + guard let host = self.trimmed(host), !host.isEmpty else { return nil } + return host + } + static func serviceEndpoint( for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)? { @@ -12,15 +23,15 @@ enum GatewayDiscoveryHelpers { serviceHost: String?, servicePort: Int?) -> (host: String, port: Int)? { - guard let host = self.trimmed(serviceHost), !host.isEmpty else { return nil } + guard let host = self.resolvedServiceHost(serviceHost) else { return nil } guard let port = servicePort, port > 0, port <= 65535 else { return nil } return (host, port) } static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - guard let endpoint = self.serviceEndpoint(for: gateway) else { return nil } + guard let host = self.resolvedServiceHost(for: gateway) else { return nil } let user = NSUserName() - var target = "\(user)@\(endpoint.host)" + var target = "\(user)@\(host)" if gateway.sshPort != 22 { target += ":\(gateway.sshPort)" } diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index c91a82d8130..60cfdfb1d73 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -676,16 +676,16 @@ extension GeneralSettings { MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) if self.state.remoteTransport == .direct { - if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { - self.state.remoteUrl = url - } - } else if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) { - self.state.remoteTarget = target + self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + } else { + self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" } if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { OpenClawConfigFile.setRemoteGatewayUrl( host: endpoint.host, port: endpoint.port) + } else { + OpenClawConfigFile.clearRemoteGatewayUrl() } } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index 2f822cb39fe..bcd5bd6d44d 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -26,16 +26,16 @@ extension OnboardingView { GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) if self.state.remoteTransport == .direct { - if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { - self.state.remoteUrl = url - } - } else if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) { - self.state.remoteTarget = target + self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + } else { + self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" } if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { OpenClawConfigFile.setRemoteGatewayUrl( host: endpoint.host, port: endpoint.port) + } else { + OpenClawConfigFile.clearRemoteGatewayUrl() } self.state.connectionMode = .remote diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index f49f2b7e0d4..35744baeda5 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -223,6 +223,19 @@ enum OpenClawConfigFile { } } + static func clearRemoteGatewayUrl() { + self.updateGatewayDict { gateway in + guard var remote = gateway["remote"] as? [String: Any] else { return } + guard remote["url"] != nil else { return } + remote.removeValue(forKey: "url") + if remote.isEmpty { + gateway.removeValue(forKey: "remote") + } else { + gateway["remote"] = remote + } + } + } + private static func remoteGatewayUrl() -> URL? { let root = self.loadDict() guard let gateway = root["gateway"] as? [String: Any], diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift index 63bb1fc5742..17ffec07d46 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift @@ -42,6 +42,21 @@ struct GatewayDiscoveryHelpersTests { #expect(parsed?.port == 2201) } + @Test func sshTargetAllowsMissingResolvedServicePort() { + let gateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: nil, + sshPort: 2201) + + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else { + Issue.record("expected ssh target") + return + } + let parsed = CommandResolver.parseSSHTarget(target) + #expect(parsed?.host == "resolved.example.ts.net") + #expect(parsed?.port == 2201) + } + @Test func sshTargetRejectsTxtOnlyGateways() { let gateway = self.makeGateway( serviceHost: nil, diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 57912eb412d..b824b2b0835 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -1,3 +1,4 @@ +import Foundation import OpenClawDiscovery import SwiftUI import Testing @@ -25,4 +26,36 @@ struct OnboardingViewSmokeTests { let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) #expect(!order.contains(8)) } + + @Test func selectRemoteGatewayClearsStaleSshTargetWhenEndpointUnresolved() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteTarget = "user@old-host:2222" + let view = OnboardingView( + state: state, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) + let gateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Unresolved", + serviceHost: nil, + servicePort: nil, + lanHost: "txt-host.local", + tailnetDns: "txt-host.ts.net", + sshPort: 22, + gatewayPort: 18789, + cliPath: "/tmp/openclaw", + stableID: UUID().uuidString, + debugID: UUID().uuidString, + isLocal: false) + + view.selectRemoteGateway(gateway) + #expect(state.remoteTarget.isEmpty) + } + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index 98e4e8046d3..2cd9d6432e2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -62,6 +62,31 @@ struct OpenClawConfigFileTests { } } + @MainActor + @Test + func clearRemoteGatewayUrlRemovesOnlyUrlField() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "wss://old-host:111", + "token": "tok", + ], + ], + ]) + OpenClawConfigFile.clearRemoteGatewayUrl() + let root = OpenClawConfigFile.loadDict() + let remote = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:] + #expect((remote["url"] as? String) == nil) + #expect((remote["token"] as? String) == "tok") + } + } + @Test func stateDirOverrideSetsConfigPath() async { let dir = FileManager().temporaryDirectory