mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
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:
committed by
GitHub
parent
1f7e2f0dc6
commit
0335317679
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
136
apps/macos/Sources/OpenClaw/GatewayConfigConflict.swift
Normal file
136
apps/macos/Sources/OpenClaw/GatewayConfigConflict.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -124,6 +124,8 @@ extension OnboardingView {
|
||||
}
|
||||
}
|
||||
|
||||
GatewayConfigConflictRecoveryView(state: self.state)
|
||||
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Button("Set up later") {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user