mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 18:01:41 +00:00
* fix(crestodian): keep onboarding RPCs restart-safe * fix(profiles): isolate approval state migrations * fix(crestodian): bypass configured gateway setup * test(crestodian): type onboarding mocks * fix(onboarding): require inference before Crestodian * fix(onboarding): enforce verified inference handoff * fix(macos): reset setup on gateway endpoint edits * chore(i18n): refresh native source inventory * fix(gateway): keep socket on request cancellation * test(packaging): require workspace templates * fix(onboarding): bind setup to verified inference * fix(onboarding): align inference gate contracts * fix(crestodian): classify concurrent policy rejection * test(crestodian): expect registry restoration * fix(onboarding): bind setup to configured gateways * fix(codex): preserve startup phase deadlines * test(crestodian): match fail-closed policy ordering * test(onboarding): assert bound gateway handoff * fix(codex): bind runtime resolution to spawn cwd * test(crestodian): assert policy rejection order * fix(cli): preserve gateway routing across restarts * fix(macos): fail closed during gateway edits * test(macos): cover gateway route generation races * chore: keep release notes out of onboarding PR * fix(ci): refresh onboarding generated checks * style(swift): align gateway channel formatting * fix(ci): refresh plugin SDK surface budgets * fix(ci): resync native string inventory * refactor(swift): split gateway channel support * test(doctor): isolate plugin compatibility registry * test(macos): isolate gateway onboarding fixtures * test(macos): assert gateway lease health ordering * fix(codex): reconcile computer-use startup changes
768 lines
29 KiB
Swift
768 lines
29 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 `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: "",
|
|
remoteTokenDirty: false)
|
|
|
|
#expect(!AppState._testGatewayDraftCanPersist(base))
|
|
#expect(AppState._testGatewayDraftCanPersist(.init(
|
|
connectionMode: .remote,
|
|
remoteTransport: .direct,
|
|
remoteTarget: "",
|
|
remoteIdentity: "",
|
|
remoteUrl: "wss://gateway.example.test",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false)))
|
|
#expect(!AppState._testGatewayDraftCanPersist(.init(
|
|
connectionMode: .remote,
|
|
remoteTransport: .ssh,
|
|
remoteTarget: "",
|
|
remoteIdentity: "",
|
|
remoteUrl: "ws://127.0.0.1:18789",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false)))
|
|
}
|
|
|
|
@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 `config watcher endpoint replacement clears and ignores stale discovery identity`() {
|
|
let previousGatewayPreference = captureGatewayPreference()
|
|
let previousPending = UserDefaults.standard.object(forKey: onboardingCrestodianPendingKey)
|
|
defer {
|
|
restoreGatewayPreference(previousGatewayPreference)
|
|
if let previousPending {
|
|
UserDefaults.standard.set(previousPending, forKey: onboardingCrestodianPendingKey)
|
|
} else {
|
|
OnboardingCrestodianResumeStore.clear()
|
|
}
|
|
}
|
|
let state = AppState(preview: true)
|
|
state.connectionMode = .remote
|
|
state.remoteTransport = .direct
|
|
state.remoteUrl = "wss://gateway-a.example.test"
|
|
GatewayDiscoveryPreferences.setPreferredStableID("gateway-a")
|
|
OnboardingCrestodianResumeStore.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 = OnboardingCrestodianResumeStore.selectedRouteIdentity(
|
|
state: state,
|
|
preferredGatewayID: view.effectivePreferredGatewayID)
|
|
#expect(routeIdentity?.hasPrefix("remote:direct:") == true)
|
|
#expect(routeIdentity != "remote:id:gateway-a")
|
|
#expect(!OnboardingCrestodianResumeStore.isPending(for: routeIdentity))
|
|
#expect(OnboardingCrestodianResumeStore.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.connectionMode = .remote
|
|
state.remoteTransport = .ssh
|
|
state.remoteUrl = "ws://127.0.0.1:18789"
|
|
state.remoteTarget = "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.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"
|
|
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 ",
|
|
remoteTokenDirty: true))
|
|
|
|
#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: " ",
|
|
remoteTokenDirty: true))
|
|
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
let changedTarget = AppState._testUpdatedRemoteGatewayConfig(
|
|
current: [
|
|
"sshHostKeyPolicy": "openssh",
|
|
"sshTarget": "old-gateway-alias",
|
|
],
|
|
draft: .init(
|
|
transport: .ssh,
|
|
remoteUrl: "",
|
|
remoteHost: nil,
|
|
remoteTarget: "new-gateway-alias",
|
|
remoteIdentity: "",
|
|
remoteToken: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
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: "",
|
|
remoteTokenDirty: false))
|
|
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 ",
|
|
remoteTokenDirty: true))
|
|
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
#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: " ",
|
|
remoteTokenDirty: true))
|
|
#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: "",
|
|
remoteTokenDirty: false))
|
|
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
|
|
}
|
|
}
|