Files
openclaw/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift
Peter Steinberger 0335317679 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
2026-08-02 11:21:07 -07:00

1104 lines
43 KiB
Swift

import Foundation
import Testing
@testable import OpenClaw
private struct StoredGatewayPreference {
let stableID: String?
let routeBinding: String?
}
private func captureGatewayPreference() -> StoredGatewayPreference {
StoredGatewayPreference(
stableID: GatewayDiscoveryPreferences.preferredStableID(),
routeBinding: GatewayDiscoveryPreferences.preferredRouteBinding())
}
private func restoreGatewayPreference(_ preference: StoredGatewayPreference) {
GatewayDiscoveryPreferences.setPreferredStableID(
preference.stableID,
routeBinding: preference.routeBinding)
}
private actor GatewayConfigReadGate {
private var started = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiter: CheckedContinuation<Void, Never>?
func suspendRead() async {
self.started = true
for waiter in self.startWaiters {
waiter.resume()
}
self.startWaiters.removeAll()
await withCheckedContinuation { continuation in
self.releaseWaiter = continuation
}
}
func waitUntilStarted() async {
guard !self.started else { return }
await withCheckedContinuation { continuation in
self.startWaiters.append(continuation)
}
}
func release() {
self.releaseWaiter?.resume()
self.releaseWaiter = nil
}
}
@Suite(.serialized)
@MainActor
struct AppStateRemoteConfigTests {
@Test
func `config fingerprint ignores writer bookkeeping metadata`() {
let base: [String: Any] = [
"gateway": ["mode": "local"],
]
let touched: [String: Any] = [
"gateway": ["mode": "local"],
"meta": [
"lastTouchedAt": "2026-07-13T09:12:53Z",
"lastTouchedVersion": "2026.7.2",
],
]
let changed: [String: Any] = [
"gateway": ["mode": "remote"],
"meta": [
"lastTouchedAt": "2026-07-13T09:13:25Z",
"lastTouchedVersion": "2026.7.2",
],
]
#expect(AppState._testConfigFingerprint(base) == AppState._testConfigFingerprint(touched))
#expect(AppState._testConfigFingerprint(base) != AppState._testConfigFingerprint(changed))
}
@Test
func `route edit during config read fails the source snapshot closed`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [connectionModeKey: AppState.ConnectionMode.remote.rawValue])
{
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway-a.example.test",
"token": "route-a-token",
],
],
]))
let state = AppState(preview: true)
let gate = GatewayConfigReadGate()
let read = Task {
await GatewayEndpointStore._testLiveSourceSnapshot(
state: state,
beforeConfigRead: { await gate.suspendRead() })
}
await gate.waitUntilStarted()
state.remoteUrl = "wss://gateway-b.example.test"
await gate.release()
let source = await read.value
#expect(source.mode == .unconfigured)
#expect(source.token == nil)
#expect(source.password == nil)
#expect(source.deviceAuthGatewayID == nil)
#expect(source.directRemoteURL == nil)
#expect(source.sshRouteIdentity == nil)
}
}
@Test
func `unbound discovery selection cannot inherit a prior route binding`() {
let previousGatewayPreference = captureGatewayPreference()
defer { restoreGatewayPreference(previousGatewayPreference) }
GatewayDiscoveryPreferences.setPreferredStableID(
"gateway-a",
routeBinding: "remote:direct:wss://gateway-a.example.test:443")
GatewayDiscoveryPreferences.setPreferredStableID("gateway-b")
#expect(GatewayDiscoveryPreferences.preferredStableID() == "gateway-b")
#expect(GatewayDiscoveryPreferences.preferredRouteBinding() == nil)
}
@Test
func `invalid remote drafts cannot be persisted for a configured gateway probe`() {
let base = AppState.GatewayConfigSyncDraft(
connectionMode: .remote,
remoteTransport: .direct,
remoteTarget: "",
remoteIdentity: "",
remoteUrl: "not a gateway URL",
remoteToken: "",
dirtyFields: [])
#expect(!AppState._testGatewayDraftCanPersist(base))
#expect(AppState._testGatewayDraftCanPersist(.init(
connectionMode: .remote,
remoteTransport: .direct,
remoteTarget: "",
remoteIdentity: "",
remoteUrl: "wss://gateway.example.test",
remoteToken: "",
dirtyFields: [])))
#expect(!AppState._testGatewayDraftCanPersist(.init(
connectionMode: .remote,
remoteTransport: .ssh,
remoteTarget: "",
remoteIdentity: "",
remoteUrl: "ws://127.0.0.1:18789",
remoteToken: "",
dirtyFields: [])))
}
@Test
func `invalid remote edit retires the prior canonical gateway route`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [connectionModeKey: AppState.ConnectionMode.remote.rawValue])
{
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "alice@gateway-a.example.test",
],
],
]))
let state = AppState(preview: true)
state._testEnableGatewayConfigSync()
state.remoteTarget = ""
#expect(!state._testGatewayConfigIsCurrentForRouting)
await state._testAwaitGatewayConfigSync()
#expect(!state._testGatewayConfigIsCurrentForRouting)
let persisted = CommandResolver.connectionSettings()
#expect(persisted.target == "alice@gateway-a.example.test")
#expect(GatewayEndpointStore._testEffectiveSourceMode(
appMode: .remote,
configMode: .remote,
configIsCurrent: state._testGatewayConfigIsCurrentForRouting) == .unconfigured)
}
}
@Test
func `remote identity edit updates canonical SSH config before routing resumes`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [connectionModeKey: AppState.ConnectionMode.remote.rawValue])
{
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "alice@gateway.example.test",
"sshIdentity": "/tmp/old-identity",
],
],
]))
let state = AppState(preview: true)
state._testEnableGatewayConfigSync()
state.remoteIdentity = " /tmp/new-identity "
#expect(!state._testGatewayConfigIsCurrentForRouting)
await state._testAwaitGatewayConfigSync()
#expect(state._testGatewayConfigIsCurrentForRouting)
let persisted = CommandResolver.connectionSettings()
#expect(persisted.identity == "/tmp/new-identity")
let remote = (OpenClawConfigFile.loadDict()["gateway"] as? [String: Any])?["remote"]
as? [String: Any]
#expect(remote?["sshIdentity"] as? String == "/tmp/new-identity")
}
}
@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()
let previousPending = UserDefaults.standard.object(forKey: onboardingSystemAgentPendingKey)
defer {
restoreGatewayPreference(previousGatewayPreference)
if let previousPending {
UserDefaults.standard.set(previousPending, forKey: onboardingSystemAgentPendingKey)
} else {
OnboardingSystemAgentResumeStore.clear()
}
}
let state = AppState(preview: true)
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)
view.preferredGatewayID = "gateway-a"
state._testApplyConfigOverrides([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway-b.example.test",
],
],
])
#expect(state.remoteUrl == "wss://gateway-b.example.test")
#expect(GatewayDiscoveryPreferences.preferredStableID() == nil)
#expect(view.effectivePreferredGatewayID == nil)
let routeIdentity = OnboardingSystemAgentResumeStore.selectedRouteIdentity(
state: state,
preferredGatewayID: view.effectivePreferredGatewayID)
#expect(routeIdentity?.hasPrefix("remote:direct:") == true)
#expect(routeIdentity != "remote:id:gateway-a")
#expect(!OnboardingSystemAgentResumeStore.isPending(for: routeIdentity))
#expect(OnboardingSystemAgentResumeStore.isPending(for: "remote:id:gateway-a"))
}
@Test
func `config watcher explicit ssh target replacement clears stale discovery identity`() {
let previousGatewayPreference = captureGatewayPreference()
defer { restoreGatewayPreference(previousGatewayPreference) }
let state = AppState(preview: true)
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"
state._testApplyConfigOverrides([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "bob@gateway-b.example.test",
],
],
])
#expect(state.remoteTarget == "bob@gateway-b.example.test")
#expect(GatewayDiscoveryPreferences.preferredStableID() == nil)
#expect(view.effectivePreferredGatewayID == nil)
}
@Test
func `config watcher explicit blank SSH fields clear stale defaults`() {
let previousGatewayPreference = captureGatewayPreference()
defer { restoreGatewayPreference(previousGatewayPreference) }
let state = AppState(preview: true)
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([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": " ",
"sshIdentity": " ",
],
],
])
#expect(state.remoteTarget.isEmpty)
#expect(state.remoteIdentity.isEmpty)
#expect(GatewayDiscoveryPreferences.preferredStableID() == nil)
}
@Test
func `cold direct config replacement clears the prior discovery owner`() async {
let configPath = TestIsolation.tempConfigPath()
let previousGatewayPreference = captureGatewayPreference()
defer { restoreGatewayPreference(previousGatewayPreference) }
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [connectionModeKey: AppState.ConnectionMode.remote.rawValue])
{
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://gateway-b.example.test",
],
],
]))
let oldBinding = GatewayDiscoveryPreferences.routeBinding(
connectionMode: .remote,
remoteTransport: .direct,
remoteURL: "wss://gateway-a.example.test",
remoteTarget: "")
GatewayDiscoveryPreferences.setPreferredStableID(
"gateway-a",
routeBinding: oldBinding)
let state = AppState(preview: true)
#expect(state.remoteUrl == "wss://gateway-b.example.test")
#expect(state._testReconcilePreferredGatewayRouteBinding())
#expect(GatewayDiscoveryPreferences.preferredStableID() == nil)
#expect(GatewayDiscoveryPreferences.preferredRouteBinding() == nil)
}
}
@Test
func `cold SSH config replacement overrides stale defaults and clears their owner`() async {
let configPath = TestIsolation.tempConfigPath()
let previousGatewayPreference = captureGatewayPreference()
defer { restoreGatewayPreference(previousGatewayPreference) }
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [
connectionModeKey: AppState.ConnectionMode.remote.rawValue,
remoteTargetKey: "alice@gateway-a.example.test",
remoteIdentityKey: "/tmp/gateway-a-id",
]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": "bob@gateway-b.example.test",
"sshIdentity": "/tmp/gateway-b-id",
],
],
]))
let oldBinding = GatewayDiscoveryPreferences.routeBinding(
connectionMode: .remote,
remoteTransport: .ssh,
remoteURL: "ws://127.0.0.1:18789",
remoteTarget: "alice@gateway-a.example.test")
GatewayDiscoveryPreferences.setPreferredStableID(
"gateway-a",
routeBinding: oldBinding)
let state = AppState(preview: true)
let settings = CommandResolver.connectionSettings()
#expect(state.remoteTarget == "bob@gateway-b.example.test")
#expect(state.remoteIdentity == "/tmp/gateway-b-id")
#expect(settings.target == "bob@gateway-b.example.test")
#expect(settings.identity == "/tmp/gateway-b-id")
#expect(state._testReconcilePreferredGatewayRouteBinding())
#expect(GatewayDiscoveryPreferences.preferredStableID() == nil)
#expect(GatewayDiscoveryPreferences.preferredRouteBinding() == nil)
}
}
@Test
func `cold explicit blank SSH fields clear stale defaults`() async {
let configPath = TestIsolation.tempConfigPath()
let previousGatewayPreference = captureGatewayPreference()
defer { restoreGatewayPreference(previousGatewayPreference) }
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [
connectionModeKey: AppState.ConnectionMode.remote.rawValue,
remoteTargetKey: "alice@gateway-a.example.test",
remoteIdentityKey: "/tmp/gateway-a-id",
]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"transport": "ssh",
"url": "ws://127.0.0.1:18789",
"sshTarget": " ",
"sshIdentity": " ",
],
],
]))
let state = AppState(preview: true)
let settings = CommandResolver.connectionSettings()
#expect(state.remoteTarget.isEmpty)
#expect(state.remoteIdentity.isEmpty)
#expect(settings.target.isEmpty)
#expect(settings.identity.isEmpty)
}
}
@Test
func `updated remote gateway config sets trimmed token`() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: [:],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: "gateway.example",
remoteTarget: "alice@gateway.example",
remoteIdentity: "/tmp/id_ed25519",
remoteToken: " secret-token ",
dirtyFields: [.remoteToken]))
#expect(remote["token"] as? String == "secret-token")
}
@Test
func `updated remote gateway config clears token when blank`() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["token": "old-token"],
draft: .init(
transport: .direct,
remoteUrl: "wss://gateway.example",
remoteHost: nil,
remoteTarget: "",
remoteIdentity: "",
remoteToken: " ",
dirtyFields: [.remoteToken]))
#expect((remote["token"] as? String) == nil)
}
@Test
func `updated remote gateway config pins loopback url for ssh transport`() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://gateway.example:18789"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: "gateway.example",
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
dirtyFields: [.remoteTransport, .remoteUrl, .remoteTarget, .remoteHostKeyPolicy]))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
#expect(remote["transport"] as? String == "ssh")
#expect(remote["sshTarget"] as? String == "alice@gateway.example")
}
@Test
func `updated remote gateway config keeps OpenSSH opt in only for the same target`() {
let sameTarget = AppState._testUpdatedRemoteGatewayConfig(
current: [
"sshHostKeyPolicy": "openssh",
"sshTarget": "alice@gateway.example",
],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: nil,
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
dirtyFields: [.remoteTarget, .remoteHostKeyPolicy]))
let changedTarget = AppState._testUpdatedRemoteGatewayConfig(
current: [
"sshHostKeyPolicy": "openssh",
"sshTarget": "old-gateway-alias",
],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: nil,
remoteTarget: "new-gateway-alias",
remoteIdentity: "",
remoteToken: "",
dirtyFields: [.remoteTarget, .remoteHostKeyPolicy]))
#expect(sameTarget["sshHostKeyPolicy"] as? String == "openssh")
#expect(changedTarget["sshHostKeyPolicy"] as? String == "strict")
}
@Test
func `updated remote gateway config preserves custom loopback tunnel port`() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://localhost.:29876"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: "gateway.example",
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
dirtyFields: [.remoteUrl]))
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
}
@Test
func `updated remote gateway config preserves custom port when existing host matches ssh target`() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://gateway.example:19999"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: nil,
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
dirtyFields: [.remoteUrl]))
#expect(remote["url"] as? String == "ws://127.0.0.1:19999")
}
@Test
func `updated remote gateway config drops custom port when existing host does not match ssh target`() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://other-host.example:19999"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: "gateway.example",
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteToken: "",
dirtyFields: [.remoteUrl]))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
}
@Test
func `updated remote gateway config does not preserve port for hostname prefix collision`() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: ["url": "ws://example.attacker.tld:19999"],
draft: .init(
transport: .ssh,
remoteUrl: "",
remoteHost: nil,
remoteTarget: "alice@example.com",
remoteIdentity: "",
remoteToken: "",
dirtyFields: [.remoteUrl]))
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
}
@Test
func `app state init does not infer loopback host into remote target`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [remoteTargetKey: nil])
{
OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"url": "ws://127.0.0.1:19999",
],
],
])
let state = AppState(preview: true)
#expect(state.remoteTarget.isEmpty)
}
}
@Test
func `app state init preserves existing remote target when remote url is loopback`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [remoteTargetKey: "alice@gateway.example"])
{
OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"url": "ws://127.0.0.1:19999",
],
],
])
let state = AppState(preview: true)
#expect(state.remoteTarget == "alice@gateway.example")
}
}
@Test
func `app state init preserves legacy SSH tunnel config until transport is explicit`() async {
let configPath = TestIsolation.tempConfigPath()
await TestIsolation.withIsolatedState(
env: ["OPENCLAW_CONFIG_PATH": configPath],
defaults: [remoteTargetKey: nil])
{
OpenClawConfigFile.saveDict([
"gateway": [
"mode": "remote",
"remote": [
"url": "ws://127.0.0.1:18789",
"sshTarget": "steipete@192.168.0.202",
],
],
])
let state = AppState(preview: true)
#expect(state.remoteTransport == .ssh)
#expect(state.remoteUrl == "ws://127.0.0.1:18789")
}
}
@Test
func `synced gateway root preserves object token across mode and transport changes when untouched`() {
let initialRoot: [String: Any] = [
"gateway": [
"mode": "remote",
"remote": [
"transport": "direct",
"url": "wss://old-gateway.example",
"token": [
"$secretRef": "gateway-token", // pragma: allowlist secret
],
],
],
]
let sshRoot = AppState._testSyncedGatewayRoot(
currentRoot: initialRoot,
draft: .init(
connectionMode: .remote,
remoteTransport: .ssh,
remoteTarget: "alice@gateway.example",
remoteIdentity: "",
remoteUrl: "",
remoteToken: "",
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
let localRoot = AppState._testSyncedGatewayRoot(
currentRoot: sshRoot,
draft: .init(
connectionMode: .local,
remoteTransport: .ssh,
remoteTarget: "",
remoteIdentity: "",
remoteUrl: "",
remoteToken: "",
dirtyFields: [.mode]))
let localGateway = localRoot["gateway"] as? [String: Any]
let localRemote = localGateway?["remote"] as? [String: Any]
#expect(localGateway?["mode"] as? String == "local")
#expect((localRemote?["token"] as? [String: String])?["$secretRef"] ==
"gateway-token") // pragma: allowlist secret
}
@Test
func `updated remote gateway config replaces object token when user enters plaintext`() {
let remote = AppState._testUpdatedRemoteGatewayConfig(
current: [
"token": [
"$secretRef": "gateway-token", // pragma: allowlist secret
],
],
draft: .init(
transport: .direct,
remoteUrl: "wss://gateway.example",
remoteHost: nil,
remoteTarget: "",
remoteIdentity: "",
remoteToken: " fresh-token ",
dirtyFields: [.remoteToken]))
#expect(remote["token"] as? String == "fresh-token")
}
@Test
func `updated remote gateway config clears object token only after explicit edit`() {
let current: [String: Any] = [
"token": [
"$secretRef": "gateway-token", // pragma: allowlist secret
],
]
let preserved = AppState._testUpdatedRemoteGatewayConfig(
current: current,
draft: .init(
transport: .direct,
remoteUrl: "wss://gateway.example",
remoteHost: nil,
remoteTarget: "",
remoteIdentity: "",
remoteToken: "",
dirtyFields: []))
#expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret
let cleared = AppState._testUpdatedRemoteGatewayConfig(
current: current,
draft: .init(
transport: .direct,
remoteUrl: "wss://gateway.example",
remoteHost: nil,
remoteTarget: "",
remoteIdentity: "",
remoteToken: " ",
dirtyFields: [.remoteToken]))
#expect((cleared["token"] as? String) == nil)
}
@Test
func `synced gateway root preserves gateway auth across mode changes`() {
let initialRoot: [String: Any] = [
"gateway": [
"mode": "remote",
"auth": [
"mode": "token",
"token": "test-token", // pragma: allowlist secret
],
"remote": [
"transport": "direct",
"url": "wss://old-gateway.example",
],
],
]
let localRoot = AppState._testSyncedGatewayRoot(
currentRoot: initialRoot,
draft: .init(
connectionMode: .local,
remoteTransport: .ssh,
remoteTarget: "",
remoteIdentity: "",
remoteUrl: "",
remoteToken: "",
dirtyFields: [.mode]))
let localGateway = localRoot["gateway"] as? [String: Any]
let auth = localGateway?["auth"] as? [String: Any]
#expect(localGateway?["mode"] as? String == "local")
#expect(auth?["mode"] as? String == "token")
#expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret
}
}