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/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/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/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. - 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. - 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. - 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 import OpenClawDiscovery
enum GatewayDiscoveryHelpers { 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( static func serviceEndpoint(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)? for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)?
{ {
@@ -12,15 +23,15 @@ enum GatewayDiscoveryHelpers {
serviceHost: String?, serviceHost: String?,
servicePort: Int?) -> (host: String, port: Int)? 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 } guard let port = servicePort, port > 0, port <= 65535 else { return nil }
return (host, port) return (host, port)
} }
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { 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() let user = NSUserName()
var target = "\(user)@\(endpoint.host)" var target = "\(user)@\(host)"
if gateway.sshPort != 22 { if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)" target += ":\(gateway.sshPort)"
} }

View File

@@ -676,16 +676,16 @@ extension GeneralSettings {
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
if self.state.remoteTransport == .direct { if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
self.state.remoteUrl = url } else {
} self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
} else if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) {
self.state.remoteTarget = target
} }
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl( OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host, host: endpoint.host,
port: endpoint.port) port: endpoint.port)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
} }
} }
} }

View File

@@ -26,16 +26,16 @@ extension OnboardingView {
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if self.state.remoteTransport == .direct { if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
self.state.remoteUrl = url } else {
} self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
} else if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) {
self.state.remoteTarget = target
} }
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl( OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host, host: endpoint.host,
port: endpoint.port) port: endpoint.port)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
} }
self.state.connectionMode = .remote 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? { private static func remoteGatewayUrl() -> URL? {
let root = self.loadDict() let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any], guard let gateway = root["gateway"] as? [String: Any],

View File

@@ -42,6 +42,21 @@ struct GatewayDiscoveryHelpersTests {
#expect(parsed?.port == 2201) #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() { @Test func sshTargetRejectsTxtOnlyGateways() {
let gateway = self.makeGateway( let gateway = self.makeGateway(
serviceHost: nil, serviceHost: nil,

View File

@@ -1,3 +1,4 @@
import Foundation
import OpenClawDiscovery import OpenClawDiscovery
import SwiftUI import SwiftUI
import Testing import Testing
@@ -25,4 +26,36 @@ struct OnboardingViewSmokeTests {
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(8)) #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 @Test
func stateDirOverrideSetsConfigPath() async { func stateDirOverrideSetsConfigPath() async {
let dir = FileManager().temporaryDirectory let dir = FileManager().temporaryDirectory