fix(macos): preserve external remote gateway config edits (#118046)

* fix(macos): reconcile external gateway config edits

* refactor(macos): split gateway reconciliation helpers

* chore(macos): refresh native i18n inventory

* refactor(macos): remove obsolete gateway config helper

* test(macos): seed watcher fixtures as canonical

* fix(macos): add gateway config conflict recovery
This commit is contained in:
Peter Steinberger
2026-08-02 11:21:07 -07:00
committed by GitHub
parent 1f7e2f0dc6
commit 0335317679
10 changed files with 1062 additions and 409 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -85,12 +85,13 @@ final class AppState {
@ObservationIgnored private let execApprovalsDefaultsAsyncResolver:
@MainActor () async -> Result<ExecApprovalsResolvedDefaults, ExecApprovalsReadError>
@ObservationIgnored private let execApprovalsReadRetryDelay: Duration
@ObservationIgnored private let gatewayConfigSaver: ([String: Any]) -> Bool
@ObservationIgnored let bundleLocationAllowsPersistentIntegration: Bool
@ObservationIgnored private var execApprovalsReadRetryTask: Task<Void, Never>?
@ObservationIgnored private var execApprovalsReadGeneration = 0
@ObservationIgnored private var isHydratingLaunchAtLogin = false
private var isInitializing = true
private var isApplyingRemoteTokenConfig = false
private var isApplyingGatewayConfig = false
private enum GatewayConfigSyncState: Equatable {
case current
case pending
@@ -105,6 +106,9 @@ final class AppState {
#endif
private var configWatcher: ConfigFileWatcher?
private var lastConfigFingerprint: Data?
private var lastObservedGatewayConfig: GatewayConfigSnapshot = .empty
private var dirtyGatewayConfigFields: Set<GatewayConfigField> = []
private var conflictedGatewayConfigFields: Set<GatewayConfigField> = []
private var suppressVoiceWakeGlobalSync = false
@ObservationIgnored private let voiceWakeGlobalSyncScheduler = VoiceWakeGlobalSyncScheduler()
@ObservationIgnored private var activeComputerPresenceTask: Task<Void, Never>?
@@ -294,12 +298,26 @@ final class AppState {
var connectionMode: ConnectionMode {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
if oldValue != self.connectionMode {
self.markGatewayConfigDirty([.mode])
}
syncGatewayConfigIfNeeded()
}
}
var remoteTransport: RemoteTransport {
didSet { syncGatewayConfigIfNeeded() }
didSet {
if oldValue != self.remoteTransport {
let fields: Set<GatewayConfigField> = switch self.remoteTransport {
case .direct:
[.remoteTransport, .remoteUrl]
case .ssh:
[.remoteTransport, .remoteUrl, .remoteTarget, .remoteIdentity, .remoteHostKeyPolicy]
}
self.markGatewayConfigDirty(fields)
}
syncGatewayConfigIfNeeded()
}
}
var canvasEnabled: Bool {
@@ -344,29 +362,58 @@ final class AppState {
var remoteTarget: String {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
if oldValue != self.remoteTarget {
self.markGatewayConfigDirty([.remoteTarget, .remoteUrl, .remoteHostKeyPolicy])
}
syncGatewayConfigIfNeeded()
}
}
var remoteUrl: String {
didSet { syncGatewayConfigIfNeeded() }
didSet {
if oldValue != self.remoteUrl {
self.markGatewayConfigDirty([.remoteUrl])
}
syncGatewayConfigIfNeeded()
}
}
var remoteToken: String {
didSet {
guard !self.isApplyingRemoteTokenConfig else { return }
self.remoteTokenDirty = true
guard oldValue != self.remoteToken else { return }
self.markGatewayConfigDirty([.remoteToken])
self.remoteTokenUnsupported = false
syncGatewayConfigIfNeeded()
}
}
private(set) var remoteTokenDirty = false
var remoteTokenDirty: Bool {
self.dirtyGatewayConfigFields.contains(.remoteToken)
}
var gatewayConfigConflict: GatewayConfigConflict? {
let fields = GatewayConfigField.allCases.filter(self.conflictedGatewayConfigFields.contains)
guard !fields.isEmpty else { return nil }
let fieldNames = fields.map(\.displayName)
let fieldList = ListFormatter.localizedString(byJoining: fieldNames)
let message = String(localized: """
These settings changed outside the app while you were editing: \(fieldList). \
Choose which version to keep.
""")
return GatewayConfigConflict(
fields: fields,
fieldNames: fieldNames,
message: message)
}
private(set) var remoteTokenUnsupported = false
var remoteIdentity: String {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) }
if oldValue != self.remoteIdentity {
self.markGatewayConfigDirty([.remoteIdentity])
}
syncGatewayConfigIfNeeded()
}
}
@@ -389,7 +436,8 @@ final class AppState {
> = {
await ExecApprovalsStore.resolveDefaultsAsyncResult()
},
execApprovalsReadRetryDelay: Duration = .milliseconds(250))
execApprovalsReadRetryDelay: Duration = .milliseconds(250),
gatewayConfigSaver: @escaping ([String: Any]) -> Bool = { OpenClawConfigFile.saveDict($0) })
{
let isPreview = preview || ProcessInfo.processInfo.isRunningTests
self.isPreview = isPreview
@@ -397,6 +445,7 @@ final class AppState {
isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration()
self.execApprovalsDefaultsAsyncResolver = execApprovalsDefaultsAsyncResolver
self.execApprovalsReadRetryDelay = execApprovalsReadRetryDelay
self.gatewayConfigSaver = gatewayConfigSaver
let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey)
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false
@@ -465,6 +514,7 @@ final class AppState {
let configRoot = OpenClawConfigFile.loadDict()
self.lastConfigFingerprint = Self.configFingerprint(configRoot)
self.lastObservedGatewayConfig = Self.gatewayConfigSnapshot(configRoot)
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
let configRemoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
let configRemoteTransport = configRemoteResolution.transport
@@ -495,7 +545,6 @@ final class AppState {
}
self.remoteUrl = configRemoteUrl ?? ""
self.remoteToken = configRemoteToken.textFieldValue
self.remoteTokenDirty = false
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
let hasConfigRemoteIdentity = configRemote?.keys.contains("sshIdentity") == true
let configRemoteIdentity = (configRemote?["sshIdentity"] as? String)?
@@ -619,18 +668,20 @@ final class AppState {
}
return false
}
}
extension AppState {
private func markGatewayConfigDirty(_ fields: Set<GatewayConfigField>) {
guard !self.isInitializing, !self.isApplyingGatewayConfig else { return }
self.dirtyGatewayConfigFields.formUnion(fields)
}
private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) {
let nextToken = tokenValue.textFieldValue
let unsupported = tokenValue.isUnsupportedNonString
guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported
else {
return
if self.remoteToken != nextToken {
self.remoteToken = nextToken
}
self.isApplyingRemoteTokenConfig = true
self.remoteToken = nextToken
self.isApplyingRemoteTokenConfig = false
self.remoteTokenDirty = false
self.remoteTokenUnsupported = unsupported
}
@@ -643,36 +694,51 @@ final class AppState {
switch draft.transport {
case .direct:
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.direct.rawValue) || changed
if draft.dirtyFields.contains(.remoteTransport) {
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.direct.rawValue) || changed
}
let trimmedUrl = draft.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed
} else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) {
changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed
if draft.dirtyFields.contains(.remoteUrl) {
let trimmedUrl = draft.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed
} else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) {
changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed
}
}
case .ssh:
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.ssh.rawValue) || changed
if draft.dirtyFields.contains(.remoteTransport) {
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.ssh.rawValue) || changed
}
let existingTarget = Self.sanitizeSSHTarget(remote["sshTarget"] as? String ?? "")
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
if existingTarget != sanitizedTarget {
if draft.dirtyFields.contains(.remoteUrl) {
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
}
if draft.dirtyFields.contains(.remoteTarget) {
changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed
}
if draft.dirtyFields.contains(.remoteIdentity) {
changed = Self.updateGatewayString(
&remote,
key: "sshIdentity",
value: draft.remoteIdentity) || changed
}
if draft.dirtyFields.contains(.remoteHostKeyPolicy), existingTarget != sanitizedTarget {
changed = Self.updateGatewayString(
&remote,
key: "sshHostKeyPolicy",
@@ -680,7 +746,7 @@ final class AppState {
}
}
if draft.remoteTokenDirty {
if draft.dirtyFields.contains(.remoteToken) {
changed = Self.updateGatewayString(&remote, key: "token", value: draft.remoteToken) || changed
}
@@ -723,9 +789,81 @@ final class AppState {
return try? JSONSerialization.data(withJSONObject: comparableRoot, options: [.sortedKeys])
}
private func applyConfigOverrides(_ root: [String: Any]) {
advanceGatewayRoutingGeneration()
let previousSelection = self.gatewaySelectionSnapshot()
private static func gatewayConfigSnapshot(_ root: [String: Any]) -> GatewayConfigSnapshot {
let gateway = root["gateway"] as? [String: Any]
let remote = gateway?["remote"] as? [String: Any]
var values: [GatewayConfigField: GatewayConfigValue] = [:]
for field in GatewayConfigField.allCases {
let value = if let remoteKey = field.remoteKey {
remote?[remoteKey]
} else {
gateway?["mode"]
}
guard let value else {
values[field] = .missing
continue
}
let data = try? JSONSerialization.data(
withJSONObject: ["value": value],
options: [.sortedKeys])
values[field] = data.map(GatewayConfigValue.json) ?? .missing
}
return GatewayConfigSnapshot(values: values)
}
private func gatewayConfigDraft() -> GatewayConfigSyncDraft {
GatewayConfigSyncDraft(
connectionMode: self.connectionMode,
remoteTransport: self.remoteTransport,
remoteTarget: self.remoteTarget,
remoteIdentity: self.remoteIdentity,
remoteUrl: self.remoteUrl,
remoteToken: self.remoteToken,
dirtyFields: self.dirtyGatewayConfigFields)
}
private static func gatewayConfigFieldsPersisted(by draft: GatewayConfigSyncDraft) -> Set<GatewayConfigField> {
var fields = draft.dirtyFields.intersection([.mode, .remoteTransport, .remoteUrl, .remoteToken])
if draft.remoteTransport == .ssh {
fields.formUnion(draft.dirtyFields.intersection([
.remoteTarget,
.remoteIdentity,
.remoteHostKeyPolicy,
]))
}
return fields
}
private func reconcileGatewayConfigOwnership(_ root: [String: Any]) -> Set<GatewayConfigField> {
let diskSnapshot = Self.gatewayConfigSnapshot(root)
let desiredRoot = Self.syncedGatewayRoot(
currentRoot: root,
draft: self.gatewayConfigDraft()).root
let desiredSnapshot = Self.gatewayConfigSnapshot(desiredRoot)
let priorConflicts = self.conflictedGatewayConfigFields
// Dirty fields retain the user's pending UI value. A disk value that moved
// from the last observed baseline is a conflict, never write authorization.
let persistedFields = Self.gatewayConfigFieldsPersisted(by: self.gatewayConfigDraft())
var externallyPersistedFields: Set<GatewayConfigField> = []
for field in persistedFields {
if diskSnapshot[field] == desiredSnapshot[field] {
externallyPersistedFields.insert(field)
} else if diskSnapshot[field] != self.lastObservedGatewayConfig[field] {
self.conflictedGatewayConfigFields.insert(field)
}
}
self.dirtyGatewayConfigFields.subtract(externallyPersistedFields)
self.conflictedGatewayConfigFields.subtract(externallyPersistedFields)
self.conflictedGatewayConfigFields.formIntersection(self.dirtyGatewayConfigFields)
self.lastObservedGatewayConfig = diskSnapshot
return priorConflicts
}
private func applyGatewayConfigView(
_ root: [String: Any],
forcing forcedFields: Set<GatewayConfigField> = [])
{
let gateway = root["gateway"] as? [String: Any]
let remote = gateway?["remote"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -748,25 +886,37 @@ final class AppState {
nil
}
if let desiredMode {
if desiredMode != self.connectionMode {
self.connectionMode = desiredMode
}
} else if hasRemoteUrl, self.connectionMode != .remote {
self.connectionMode = .remote
}
self.isApplyingGatewayConfig = true
self.applyGatewayConfigMode(
desiredMode,
hasRemoteUrl: hasRemoteUrl,
forcing: forcedFields.contains(.mode))
if remoteTransport != self.remoteTransport {
let shouldApplyTransport = forcedFields.contains(.remoteTransport) ||
!self.dirtyGatewayConfigFields.contains(.remoteTransport)
if shouldApplyTransport, remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteResolution.directURL?.absoluteString ?? remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
if forcedFields.contains(.remoteUrl) || !self.dirtyGatewayConfigFields.contains(.remoteUrl) {
let remoteUrlText = remoteResolution.directURL?.absoluteString ?? remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
}
if forcedFields.contains(.remoteToken) || !self.dirtyGatewayConfigFields.contains(.remoteToken) {
self.applyRemoteTokenState(remoteToken)
}
self.applyRemoteTokenState(remoteToken)
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote, remoteTransport != .direct {
if forcedFields.contains(.remoteTarget) {
let configuredTarget = Self.sanitizeSSHTarget(remote?["sshTarget"] as? String ?? "")
if configuredTarget != Self.sanitizeSSHTarget(self.remoteTarget) {
self.remoteTarget = configuredTarget
}
} else if !self.dirtyGatewayConfigFields.contains(.remoteTarget),
targetMode == .remote,
remoteTransport != .direct
{
let hasConfiguredTarget = remote?.keys.contains("sshTarget") == true
let configuredTarget = Self.sanitizeSSHTarget(remote?["sshTarget"] as? String ?? "")
if hasConfiguredTarget, configuredTarget != Self.sanitizeSSHTarget(self.remoteTarget) {
@@ -778,13 +928,42 @@ final class AppState {
self.updateRemoteTarget(host: host)
}
}
if remote?.keys.contains("sshIdentity") == true {
if forcedFields.contains(.remoteIdentity) {
let configuredIdentity = (remote?["sshIdentity"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if configuredIdentity != self.remoteIdentity {
self.remoteIdentity = configuredIdentity
}
} else if !self.dirtyGatewayConfigFields.contains(.remoteIdentity),
remote?.keys.contains("sshIdentity") == true
{
let configuredIdentity = (remote?["sshIdentity"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if configuredIdentity != self.remoteIdentity {
self.remoteIdentity = configuredIdentity
}
}
self.isApplyingGatewayConfig = false
}
private func applyGatewayConfigMode(
_ desiredMode: ConnectionMode?,
hasRemoteUrl: Bool,
forcing: Bool)
{
guard forcing || !self.dirtyGatewayConfigFields.contains(.mode) else { return }
let nextMode = desiredMode ?? (hasRemoteUrl ? .remote : forcing ? .unconfigured : nil)
if let nextMode, nextMode != self.connectionMode {
self.connectionMode = nextMode
}
}
private func applyConfigOverrides(_ root: [String: Any]) {
advanceGatewayRoutingGeneration()
let previousSelection = self.gatewaySelectionSnapshot()
let priorConflicts = self.reconcileGatewayConfigOwnership(root)
self.applyGatewayConfigView(root)
if self.gatewaySelectionSnapshot() != previousSelection {
// Discovery ids describe one concrete endpoint. An external config
// edit has no discovery selection event to update that ownership,
@@ -792,6 +971,17 @@ final class AppState {
// replacement Gateway.
GatewayDiscoveryPreferences.setPreferredStableID(nil)
}
let newConflicts = self.conflictedGatewayConfigFields.subtracting(priorConflicts)
if !newConflicts.isEmpty {
let names = self.conflictedGatewayConfigFields.map(\.rawValue).sorted().joined(separator: ", ")
Self.logger.warning("gateway config sync conflict fields=\(names)")
}
if !self.conflictedGatewayConfigFields.isEmpty {
self.setGatewayConfigSyncState(.failed)
} else if !priorConflicts.isEmpty, self.dirtyGatewayConfigFields.isEmpty {
self.setGatewayConfigSyncState(.current)
}
}
private func gatewaySelectionSnapshot() -> GatewaySelectionSnapshot {
@@ -1092,20 +1282,22 @@ extension AppState {
nil
}
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
if draft.dirtyFields.contains(.mode) {
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false
if draft.connectionMode == .remote {
if draft.dirtyFields.contains(where: { $0.remoteKey != nil }) {
let remoteHost = CommandResolver.parseSSHTarget(draft.remoteTarget)?.host
let updated = Self.updatedRemoteGatewayConfig(
current: remote,
@@ -1116,7 +1308,7 @@ extension AppState {
remoteTarget: draft.remoteTarget,
remoteIdentity: draft.remoteIdentity,
remoteToken: draft.remoteToken,
remoteTokenDirty: draft.remoteTokenDirty))
dirtyFields: draft.dirtyFields))
remote = updated.remote
remoteChanged = updated.changed
}
@@ -1146,6 +1338,7 @@ extension AppState {
}
private func syncGatewayConfigIfNeeded() {
guard !self.isApplyingGatewayConfig else { return }
self.advanceGatewayRoutingGeneration()
guard self.gatewayConfigSyncIsEnabled, !self.isInitializing else { return }
self.setGatewayConfigSyncState(.pending)
@@ -1185,7 +1378,10 @@ extension AppState {
}
private static func gatewayDraftCanPersist(_ draft: GatewayConfigSyncDraft) -> Bool {
guard draft.connectionMode == .remote else { return true }
let ownsRemoteRoute = draft.dirtyFields.contains(.remoteTransport) ||
draft.dirtyFields.contains(.remoteUrl) ||
draft.dirtyFields.contains(.remoteTarget)
guard draft.connectionMode == .remote || ownsRemoteRoute else { return true }
switch draft.remoteTransport {
case .direct:
return GatewayRemoteConfig.normalizeGatewayUrl(draft.remoteUrl) != nil
@@ -1211,14 +1407,11 @@ extension AppState {
guard self.gatewayConfigSyncIsEnabled, !self.isInitializing else { return true }
self.setGatewayConfigSyncState(.pending)
let draft = GatewayConfigSyncDraft(
connectionMode: connectionMode,
remoteTransport: remoteTransport,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity,
remoteUrl: remoteUrl,
remoteToken: remoteToken,
remoteTokenDirty: remoteTokenDirty)
let currentRoot = OpenClawConfigFile.loadDict()
self.applyConfigOverrides(currentRoot)
guard self.conflictedGatewayConfigFields.isEmpty else { return false }
let draft = self.gatewayConfigDraft()
guard Self.gatewayDraftCanPersist(draft) else {
self.setGatewayConfigSyncState(.failed)
return false
@@ -1226,23 +1419,102 @@ extension AppState {
// Keep app-only connection settings local to avoid overwriting remote gateway config.
let synced = Self.syncedGatewayRoot(
currentRoot: OpenClawConfigFile.loadDict(),
currentRoot: currentRoot,
draft: draft,
remoteTLSFingerprintUpdate: remoteTLSFingerprintUpdate)
guard synced.changed else {
self.acknowledgeGatewayConfigPersistence(draft.dirtyFields, root: currentRoot)
self.setGatewayConfigSyncState(.current)
return true
}
guard OpenClawConfigFile.saveDict(synced.root) else {
guard self.gatewayConfigSaver(synced.root) else {
self.setGatewayConfigSyncState(.failed)
Self.logger.warning("gateway config sync rejected to protect persisted gateway auth/mode")
return false
}
self.acknowledgeGatewayConfigPersistence(draft.dirtyFields, root: synced.root)
self.lastConfigFingerprint = Self.configFingerprint(synced.root)
self.setGatewayConfigSyncState(.current)
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
return true
}
private func acknowledgeGatewayConfigPersistence(
_ fields: Set<GatewayConfigField>,
root: [String: Any])
{
let persistedFields = Self.gatewayConfigFieldsPersisted(by: self.gatewayConfigDraft())
.intersection(fields)
self.dirtyGatewayConfigFields.subtract(persistedFields)
self.conflictedGatewayConfigFields.subtract(persistedFields)
self.lastObservedGatewayConfig = Self.gatewayConfigSnapshot(root)
}
@discardableResult
func useFileGatewayConfigConflict() -> Bool {
let fields = self.conflictedGatewayConfigFields
guard !fields.isEmpty else { return true }
let priorDraft = self.gatewayConfigDraft()
let priorRemoteTokenUnsupported = self.remoteTokenUnsupported
let root = OpenClawConfigFile.loadDict()
self.dirtyGatewayConfigFields.subtract(fields)
self.conflictedGatewayConfigFields.subtract(fields)
self.applyGatewayConfigView(root, forcing: fields)
guard self.syncGatewayConfigNow() else {
self.restoreGatewayConfigDraft(priorDraft, fields: fields)
self.remoteTokenUnsupported = priorRemoteTokenUnsupported
self.dirtyGatewayConfigFields.formUnion(fields)
self.conflictedGatewayConfigFields.formUnion(fields)
self.setGatewayConfigSyncState(.failed)
return false
}
return true
}
@discardableResult
func keepGatewayConfigEdits() -> Bool {
let fields = self.conflictedGatewayConfigFields
guard !fields.isEmpty else { return true }
self.conflictedGatewayConfigFields.subtract(fields)
self.dirtyGatewayConfigFields.formUnion(fields)
guard self.syncGatewayConfigNow(),
self.dirtyGatewayConfigFields.isDisjoint(with: fields)
else {
self.conflictedGatewayConfigFields.formUnion(fields)
self.setGatewayConfigSyncState(.failed)
return false
}
return true
}
private func restoreGatewayConfigDraft(
_ draft: GatewayConfigSyncDraft,
fields: Set<GatewayConfigField>)
{
self.isApplyingGatewayConfig = true
if fields.contains(.mode) {
self.connectionMode = draft.connectionMode
}
if fields.contains(.remoteTransport) {
self.remoteTransport = draft.remoteTransport
}
if fields.contains(.remoteUrl) {
self.remoteUrl = draft.remoteUrl
}
if fields.contains(.remoteTarget) {
self.remoteTarget = draft.remoteTarget
}
if fields.contains(.remoteIdentity) {
self.remoteIdentity = draft.remoteIdentity
}
if fields.contains(.remoteToken) {
self.remoteToken = draft.remoteToken
}
self.isApplyingGatewayConfig = false
}
}
extension AppState {
@@ -1334,33 +1606,6 @@ extension AppState {
case ssh
case direct
}
struct RemoteGatewayConfigDraft {
var transport: RemoteTransport
var remoteUrl: String
var remoteHost: String?
var remoteTarget: String
var remoteIdentity: String
var remoteToken: String
var remoteTokenDirty: Bool
}
struct GatewayConfigSyncDraft {
var connectionMode: ConnectionMode
var remoteTransport: RemoteTransport
var remoteTarget: String
var remoteIdentity: String
var remoteUrl: String
var remoteToken: String
var remoteTokenDirty: Bool
}
private struct GatewaySelectionSnapshot: Equatable {
let connectionMode: ConnectionMode
let remoteTransport: RemoteTransport
let remoteUrl: String
let remoteTarget: String
}
}
#if DEBUG
@@ -1396,6 +1641,10 @@ extension AppState {
self.applyConfigOverrides(root)
}
func _testApplyConfigFromDisk() {
self.applyConfigFromDisk()
}
func _testEnableGatewayConfigSync() {
self.gatewayConfigSyncEnabledForTesting = true
}
@@ -1408,6 +1657,14 @@ extension AppState {
self.gatewayConfigIsCurrentForRouting
}
var _testDirtyGatewayConfigFields: [String] {
self.dirtyGatewayConfigFields.map(\.rawValue).sorted()
}
var _testConflictedGatewayConfigFields: [String] {
self.conflictedGatewayConfigFields.map(\.rawValue).sorted()
}
@discardableResult
func _testReconcilePreferredGatewayRouteBinding() -> Bool {
self.reconcilePreferredGatewayRouteBinding()

View File

@@ -0,0 +1,136 @@
import Foundation
import SwiftUI
extension AppState {
enum GatewayConfigField: String, CaseIterable {
case mode = "gateway.mode"
case remoteTransport = "gateway.remote.transport"
case remoteUrl = "gateway.remote.url"
case remoteTarget = "gateway.remote.sshTarget"
case remoteIdentity = "gateway.remote.sshIdentity"
case remoteHostKeyPolicy = "gateway.remote.sshHostKeyPolicy"
case remoteToken = "gateway.remote.token"
var remoteKey: String? {
switch self {
case .mode:
nil
case .remoteTransport:
"transport"
case .remoteUrl:
"url"
case .remoteTarget:
"sshTarget"
case .remoteIdentity:
"sshIdentity"
case .remoteHostKeyPolicy:
"sshHostKeyPolicy"
case .remoteToken:
"token"
}
}
var displayName: String {
switch self {
case .mode:
String(localized: "Gateway location")
case .remoteTransport:
String(localized: "Transport")
case .remoteUrl:
String(localized: "Gateway URL")
case .remoteTarget:
String(localized: "SSH target")
case .remoteIdentity:
String(localized: "Identity file")
case .remoteHostKeyPolicy:
String(localized: "SSH host key policy")
case .remoteToken:
String(localized: "Gateway token")
}
}
}
struct GatewayConfigConflict: Equatable {
let fields: [GatewayConfigField]
let fieldNames: [String]
let message: String
}
enum GatewayConfigValue: Equatable {
case missing
case json(Data)
}
struct GatewayConfigSnapshot {
static let empty = GatewayConfigSnapshot(values: [:])
let values: [GatewayConfigField: GatewayConfigValue]
subscript(field: GatewayConfigField) -> GatewayConfigValue {
self.values[field] ?? .missing
}
}
struct RemoteGatewayConfigDraft {
var transport: RemoteTransport
var remoteUrl: String
var remoteHost: String?
var remoteTarget: String
var remoteIdentity: String
var remoteToken: String
var dirtyFields: Set<GatewayConfigField>
}
struct GatewayConfigSyncDraft {
var connectionMode: ConnectionMode
var remoteTransport: RemoteTransport
var remoteTarget: String
var remoteIdentity: String
var remoteUrl: String
var remoteToken: String
var dirtyFields: Set<GatewayConfigField>
}
struct GatewaySelectionSnapshot: Equatable {
let connectionMode: ConnectionMode
let remoteTransport: RemoteTransport
let remoteUrl: String
let remoteTarget: String
}
}
struct GatewayConfigConflictRecoveryView: View {
@Bindable var state: AppState
var body: some View {
if let conflict = state.gatewayConfigConflict {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 8) {
Text(verbatim: conflict.message)
.font(.footnote)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
Button("Use file version") {
self.state.useFileGatewayConfigConflict()
}
.buttonStyle(.bordered)
Button("Keep my edits") {
self.state.keepGatewayConfigEdits()
}
.buttonStyle(.borderedProminent)
}
.controlSize(.small)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(.orange.opacity(0.08))
}
}
}

View File

@@ -23,18 +23,6 @@ enum GatewayDiscoverySelectionSupport {
state.remoteUrl = self.sshTunnelGatewayUrl(current: state.remoteUrl)
}
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
if preferredTransport == .direct {
OpenClawConfigFile.setRemoteGatewayTransport(AppState.RemoteTransport.direct.rawValue)
if !state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
} else {
OpenClawConfigFile.setRemoteGatewayTransport(AppState.RemoteTransport.ssh.rawValue)
OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl)
}
}
private static func sshTunnelGatewayUrl(current: String) -> String {

View File

@@ -275,6 +275,14 @@ struct GeneralSettings: View {
self.connectionStatusPanel
self.gatewayModeGroup
if self.state.connectionMode != .remote,
self.state.gatewayConfigConflict != nil
{
SettingsCardGroup("Remote Access") {
GatewayConfigConflictRecoveryView(state: self.state)
}
}
switch self.state.connectionMode {
case .unconfigured:
EmptyView()
@@ -472,6 +480,7 @@ struct GeneralSettings: View {
self.remoteDirectRow
}
self.remoteTokenRow
GatewayConfigConflictRecoveryView(state: self.state)
}
SettingsCardGroup("Discovery & Status") {
@@ -657,7 +666,7 @@ struct GeneralSettings: View {
SettingsCardRow(
title: "Gateway token",
subtitle: "Used when the remote gateway requires token auth.",
showsDivider: false)
showsDivider: self.state.gatewayConfigConflict != nil)
{
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
.textFieldStyle(.roundedBorder)

View File

@@ -124,6 +124,8 @@ extension OnboardingView {
}
}
GatewayConfigConflictRecoveryView(state: self.state)
HStack {
Spacer(minLength: 0)
Button("Set up later") {

View File

@@ -207,18 +207,6 @@ enum OpenClawConfigFile {
}
}
static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) {
var root = self.loadDict()
var gateway = root["gateway"] as? [String: Any] ?? [:]
mutate(&gateway)
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
self.saveDict(root)
}
static func gatewayUpdateChannel() -> String? {
let root = self.loadDict()
let update = root["update"] as? [String: Any]
@@ -422,53 +410,6 @@ extension OpenClawConfigFile {
return port
}
static func setRemoteGatewayUrl(host: String, port: Int?) {
guard let port, port > 0 else { return }
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedHost.isEmpty else { return }
self.updateGatewayDict { gateway in
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let scheme = URL(string: existingUrl)?.scheme ?? "ws"
remote["url"] = "\(scheme)://\(trimmedHost):\(port)"
gateway["remote"] = remote
}
}
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 setRemoteGatewayTransport(_ 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["transport"] = trimmed
gateway["remote"] = remote
}
}
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

@@ -137,7 +137,7 @@ struct AppStateRemoteConfigTests {
remoteIdentity: "",
remoteUrl: "not a gateway URL",
remoteToken: "",
remoteTokenDirty: false)
dirtyFields: [])
#expect(!AppState._testGatewayDraftCanPersist(base))
#expect(AppState._testGatewayDraftCanPersist(.init(
@@ -147,7 +147,7 @@ struct AppStateRemoteConfigTests {
remoteIdentity: "",
remoteUrl: "wss://gateway.example.test",
remoteToken: "",
remoteTokenDirty: false)))
dirtyFields: [])))
#expect(!AppState._testGatewayDraftCanPersist(.init(
connectionMode: .remote,
remoteTransport: .ssh,
@@ -155,7 +155,7 @@ struct AppStateRemoteConfigTests {
remoteIdentity: "",
remoteUrl: "ws://127.0.0.1:18789",
remoteToken: "",
remoteTokenDirty: false)))
dirtyFields: [])))
}
@Test
@@ -226,6 +226,294 @@ struct AppStateRemoteConfigTests {
}
}
@Test
func `successful gateway config sync clears dirty fields`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway.example.test",
"token": "disk-token",
],
],
]))
let state = AppState(preview: true)
state._testEnableGatewayConfigSync()
state.remoteToken = "app-token"
#expect(state.remoteTokenDirty)
await state._testAwaitGatewayConfigSync()
#expect(!state.remoteTokenDirty)
#expect(state._testDirtyGatewayConfigFields.isEmpty)
#expect(state._testGatewayConfigIsCurrentForRouting)
let remote = (OpenClawConfigFile.loadDict()["gateway"] as? [String: Any])?["remote"]
as? [String: Any]
#expect(remote?["token"] as? String == "app-token")
state.remoteToken = " app-token "
#expect(state.remoteTokenDirty)
await state._testAwaitGatewayConfigSync()
#expect(!state.remoteTokenDirty)
}
}
@Test
func `failed gateway config sync retains dirty fields`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway.example.test",
"token": "disk-token",
],
],
]))
let state = AppState(preview: true, gatewayConfigSaver: { _ in false })
state._testEnableGatewayConfigSync()
state.remoteToken = "app-token"
await state._testAwaitGatewayConfigSync()
#expect(state.remoteTokenDirty)
#expect(state._testDirtyGatewayConfigFields == ["gateway.remote.token"])
#expect(!state._testGatewayConfigIsCurrentForRouting)
let remote = (OpenClawConfigFile.loadDict()["gateway"] as? [String: Any])?["remote"]
as? [String: Any]
#expect(remote?["token"] as? String == "disk-token")
}
}
@Test
func `config watcher adopts non-dirty remote gateway fields`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://old-gateway.example.test",
"token": "old-token",
],
],
]))
let state = AppState(preview: true)
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:19999",
"sshTarget": "alice@new-gateway.example.test",
"sshIdentity": "/tmp/new-identity",
"token": "new-token",
],
],
]))
state._testApplyConfigFromDisk()
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "ws://127.0.0.1:19999")
#expect(state.remoteTarget == "alice@new-gateway.example.test")
#expect(state.remoteIdentity == "/tmp/new-identity")
#expect(state.remoteToken == "new-token")
#expect(state._testDirtyGatewayConfigFields.isEmpty)
#expect(state._testConflictedGatewayConfigFields.isEmpty)
}
}
@Test
func `external token edit after successful sync is not reverted`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway.example.test",
"token": "initial-token",
],
],
]))
let state = AppState(preview: true)
state._testEnableGatewayConfigSync()
state.remoteToken = "app-token"
await state._testAwaitGatewayConfigSync()
#expect(!state.remoteTokenDirty)
var externalRoot = OpenClawConfigFile.loadDict()
var gateway = externalRoot["gateway"] as? [String: Any] ?? [:]
var remote = gateway["remote"] as? [String: Any] ?? [:]
remote["token"] = "external-token"
gateway["remote"] = remote
externalRoot["gateway"] = gateway
#expect(OpenClawConfigFile.saveDict(externalRoot))
#expect(state.syncGatewayConfigNow())
#expect(state.remoteToken == "external-token")
let persistedRemote = (OpenClawConfigFile.loadDict()["gateway"] as? [String: Any])?["remote"]
as? [String: Any]
#expect(persistedRemote?["token"] as? String == "external-token")
}
}
@Test
func `dirty external token conflict offers both recovery choices`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "alice@gateway.example.test",
"sshIdentity": "/tmp/initial-identity",
"token": "initial-token",
],
],
]))
var rejectSaves = false
let state = AppState(
preview: true,
gatewayConfigSaver: { root in
rejectSaves ? false : OpenClawConfigFile.saveDict(root)
})
state.remoteIdentity = "/tmp/app-identity"
state.remoteToken = "app-token"
var externalRoot = OpenClawConfigFile.loadDict()
var gateway = externalRoot["gateway"] as? [String: Any] ?? [:]
var remote = gateway["remote"] as? [String: Any] ?? [:]
remote.removeValue(forKey: "sshIdentity")
remote["token"] = "external-token"
gateway["remote"] = remote
externalRoot["gateway"] = gateway
#expect(OpenClawConfigFile.saveDict(externalRoot))
state._testApplyConfigFromDisk()
#expect(state.remoteIdentity == "/tmp/app-identity")
#expect(state.remoteToken == "app-token")
#expect(state.remoteTokenDirty)
#expect(state._testConflictedGatewayConfigFields == [
"gateway.remote.sshIdentity",
"gateway.remote.token",
])
#expect(state.gatewayConfigConflict?.fields == [.remoteIdentity, .remoteToken])
#expect(state.gatewayConfigConflict?.fieldNames == ["Identity file", "Gateway token"])
#expect(state.gatewayConfigConflict?.message ==
"These settings changed outside the app while you were editing: " +
"Identity file and Gateway token. " +
"Choose which version to keep.")
#expect(!state._testGatewayConfigIsCurrentForRouting)
state._testEnableGatewayConfigSync()
#expect(!state.syncGatewayConfigNow())
var persistedRemote = (OpenClawConfigFile.loadDict()["gateway"] as? [String: Any])?["remote"]
as? [String: Any]
#expect(persistedRemote?["token"] as? String == "external-token")
#expect(state.remoteToken == "app-token")
#expect(state.useFileGatewayConfigConflict())
#expect(state.remoteIdentity.isEmpty)
#expect(state.remoteToken == "external-token")
#expect(!state.remoteTokenDirty)
#expect(state.gatewayConfigConflict == nil)
#expect(state._testConflictedGatewayConfigFields.isEmpty)
#expect(state._testGatewayConfigIsCurrentForRouting)
state.remoteToken = "kept-token"
externalRoot = OpenClawConfigFile.loadDict()
gateway = externalRoot["gateway"] as? [String: Any] ?? [:]
remote = gateway["remote"] as? [String: Any] ?? [:]
remote["token"] = "second-external-token"
gateway["remote"] = remote
externalRoot["gateway"] = gateway
#expect(OpenClawConfigFile.saveDict(externalRoot))
state._testApplyConfigFromDisk()
#expect(state.gatewayConfigConflict?.fields == [.remoteToken])
#expect(state.keepGatewayConfigEdits())
persistedRemote = (OpenClawConfigFile.loadDict()["gateway"] as? [String: Any])?["remote"]
as? [String: Any]
#expect(persistedRemote?["token"] as? String == "kept-token")
#expect(state.remoteToken == "kept-token")
#expect(!state.remoteTokenDirty)
#expect(state.gatewayConfigConflict == nil)
#expect(state._testConflictedGatewayConfigFields.isEmpty)
#expect(state._testGatewayConfigIsCurrentForRouting)
state.remoteToken = "unsaved-token"
externalRoot = OpenClawConfigFile.loadDict()
gateway = externalRoot["gateway"] as? [String: Any] ?? [:]
remote = gateway["remote"] as? [String: Any] ?? [:]
remote["token"] = "third-external-token"
gateway["remote"] = remote
externalRoot["gateway"] = gateway
#expect(OpenClawConfigFile.saveDict(externalRoot))
state._testApplyConfigFromDisk()
rejectSaves = true
#expect(!state.keepGatewayConfigEdits())
persistedRemote = (OpenClawConfigFile.loadDict()["gateway"] as? [String: Any])?["remote"]
as? [String: Any]
#expect(persistedRemote?["token"] as? String == "third-external-token")
#expect(state.remoteToken == "unsaved-token")
#expect(state.gatewayConfigConflict?.fields == [.remoteToken])
#expect(state._testConflictedGatewayConfigFields == ["gateway.remote.token"])
#expect(!state._testGatewayConfigIsCurrentForRouting)
}
}
@Test
func `dirty token does not claim an externally changed remote URL`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://old-gateway.example.test",
"token": "initial-token",
],
],
]))
let state = AppState(preview: true)
state.remoteToken = "app-token"
var externalRoot = OpenClawConfigFile.loadDict()
var gateway = externalRoot["gateway"] as? [String: Any] ?? [:]
var remote = gateway["remote"] as? [String: Any] ?? [:]
remote["url"] = "wss://new-gateway.example.test"
gateway["remote"] = remote
externalRoot["gateway"] = gateway
#expect(OpenClawConfigFile.saveDict(externalRoot))
state._testApplyConfigFromDisk()
#expect(state.remoteToken == "app-token")
#expect(state.remoteUrl == "wss://new-gateway.example.test")
#expect(state._testConflictedGatewayConfigFields.isEmpty)
state._testEnableGatewayConfigSync()
#expect(state.syncGatewayConfigNow())
let persistedRemote = (OpenClawConfigFile.loadDict()["gateway"] as? [String: Any])?["remote"]
as? [String: Any]
#expect(persistedRemote?["token"] as? String == "app-token")
#expect(persistedRemote?["url"] as? String == "wss://new-gateway.example.test")
}
}
@Test
func `config watcher endpoint replacement clears and ignores stale discovery identity`() {
let previousGatewayPreference = captureGatewayPreference()
@@ -239,9 +527,15 @@ struct AppStateRemoteConfigTests {
}
}
let state = AppState(preview: true)
state.connectionMode = .remote
state.remoteTransport = .direct
state.remoteUrl = "wss://gateway-a.example.test"
state._testApplyConfigOverrides([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway-a.example.test",
],
],
])
GatewayDiscoveryPreferences.setPreferredStableID("gateway-a")
OnboardingSystemAgentResumeStore.markPending(routeIdentity: "remote:id:gateway-a")
let view = OnboardingView(state: state)
@@ -274,10 +568,16 @@ struct AppStateRemoteConfigTests {
let previousGatewayPreference = captureGatewayPreference()
defer { restoreGatewayPreference(previousGatewayPreference) }
let state = AppState(preview: true)
state.connectionMode = .remote
state.remoteTransport = .ssh
state.remoteUrl = "ws://127.0.0.1:18789"
state.remoteTarget = "alice@gateway-a.example.test"
state._testApplyConfigOverrides([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "alice@gateway-a.example.test",
],
],
])
GatewayDiscoveryPreferences.setPreferredStableID("gateway-a")
let view = OnboardingView(state: state)
view.preferredGatewayID = "gateway-a"
@@ -303,11 +603,17 @@ struct AppStateRemoteConfigTests {
let previousGatewayPreference = captureGatewayPreference()
defer { restoreGatewayPreference(previousGatewayPreference) }
let state = AppState(preview: true)
state.connectionMode = .remote
state.remoteTransport = .ssh
state.remoteUrl = "ws://127.0.0.1:18789"
state.remoteTarget = "alice@gateway-a.example.test"
state.remoteIdentity = "/tmp/gateway-a-id"
state._testApplyConfigOverrides([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "alice@gateway-a.example.test",
"sshIdentity": "/tmp/gateway-a-id",
],
],
])
GatewayDiscoveryPreferences.setPreferredStableID("gateway-a")
state._testApplyConfigOverrides([
@@ -456,7 +762,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "alice@gateway.example",
remoteIdentity: "/tmp/id_ed25519",
remoteToken: " secret-token ",
remoteTokenDirty: true))
dirtyFields: [.remoteToken]))
#expect(remote["token"] as? String == "secret-token")
}
@@ -472,7 +778,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "",
remoteIdentity: "",
remoteToken: " ",
remoteTokenDirty: true))
dirtyFields: [.remoteToken]))
#expect((remote["token"] as? String) == nil)
}
@@ -488,7 +794,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.remoteTransport, .remoteUrl, .remoteTarget, .remoteHostKeyPolicy]))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
#expect(remote["transport"] as? String == "ssh")
@@ -509,7 +815,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.remoteTarget, .remoteHostKeyPolicy]))
let changedTarget = AppState._testUpdatedRemoteGatewayConfig(
current: [
"sshHostKeyPolicy": "openssh",
@@ -522,7 +828,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "new-gateway-alias",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.remoteTarget, .remoteHostKeyPolicy]))
#expect(sameTarget["sshHostKeyPolicy"] as? String == "openssh")
#expect(changedTarget["sshHostKeyPolicy"] as? String == "strict")
@@ -539,7 +845,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.remoteUrl]))
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
}
@@ -555,7 +861,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.remoteUrl]))
#expect(remote["url"] as? String == "ws://127.0.0.1:19999")
}
@@ -571,7 +877,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.remoteUrl]))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
}
@@ -587,7 +893,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "alice@example.com",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.remoteUrl]))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
}
@@ -681,7 +987,13 @@ struct AppStateRemoteConfigTests {
remoteIdentity: "",
remoteUrl: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [
.remoteTransport,
.remoteUrl,
.remoteTarget,
.remoteIdentity,
.remoteHostKeyPolicy,
]))
let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
#expect((sshRemote?["token"] as? [String: String])?["$secretRef"] ==
"gateway-token") // pragma: allowlist secret
@@ -695,7 +1007,7 @@ struct AppStateRemoteConfigTests {
remoteIdentity: "",
remoteUrl: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.mode]))
let localGateway = localRoot["gateway"] as? [String: Any]
let localRemote = localGateway?["remote"] as? [String: Any]
#expect(localGateway?["mode"] as? String == "local")
@@ -718,7 +1030,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "",
remoteIdentity: "",
remoteToken: " fresh-token ",
remoteTokenDirty: true))
dirtyFields: [.remoteToken]))
#expect(remote["token"] as? String == "fresh-token")
}
@@ -740,7 +1052,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "",
remoteIdentity: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: []))
#expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
let cleared = AppState._testUpdatedRemoteGatewayConfig(
@@ -752,7 +1064,7 @@ struct AppStateRemoteConfigTests {
remoteTarget: "",
remoteIdentity: "",
remoteToken: " ",
remoteTokenDirty: true))
dirtyFields: [.remoteToken]))
#expect((cleared["token"] as? String) == nil)
}
@@ -781,7 +1093,7 @@ struct AppStateRemoteConfigTests {
remoteIdentity: "",
remoteUrl: "",
remoteToken: "",
remoteTokenDirty: false))
dirtyFields: [.mode]))
let localGateway = localRoot["gateway"] as? [String: Any]
let auth = localGateway?["auth"] as? [String: Any]
#expect(localGateway?["mode"] as? String == "local")

View File

@@ -113,11 +113,6 @@ struct GatewayDiscoverySelectionSupportTests {
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "ws://127.0.0.1:29876")
#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["transport"] as? String == "ssh")
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
}
}
@@ -138,11 +133,6 @@ struct GatewayDiscoverySelectionSupportTests {
#expect(state.remoteTransport == .direct)
#expect(state.remoteUrl == "ws://nearby-gateway.local:19999")
let configRoot = OpenClawConfigFile.loadDict()
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
#expect(remote["transport"] as? String == "direct")
#expect(remote["url"] as? String == "ws://nearby-gateway.local:19999")
}
}
}

View File

@@ -56,68 +56,6 @@ struct OpenClawConfigFileTests {
}
}
@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")
}
}
@MainActor
@Test
func `set remote gateway url preserves scheme`() async {
let override = self.makeConfigOverridePath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
OpenClawConfigFile.saveDict([
"gateway": [
"remote": [
"url": "wss://old-host:111",
],
],
])
OpenClawConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222)
let root = OpenClawConfigFile.loadDict()
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
#expect(url == "wss://new-host:2222")
}
}
@MainActor
@Test
func `clear remote gateway url removes only url field`() async {
let override = self.makeConfigOverridePath()
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 `state dir override sets config path`() async {
let dir = FileManager().temporaryDirectory