fix: clear stale remote discovery endpoints (#21618) (thanks @bmendonca3)

This commit is contained in:
Peter Steinberger
2026-02-21 23:52:38 +01:00
parent 37d5320f6b
commit bfe016fa29
8 changed files with 111 additions and 13 deletions

View File

@@ -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.

View File

@@ -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)"
}

View File

@@ -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()
}
}
}

View File

@@ -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

View File

@@ -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],

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -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