Files
openclaw/apps/ios/Tests/GatewayConnectionControllerTests.swift
Peter Steinberger ac89350327 feat(apps): review durable approvals on mobile (#104913)
* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a60619722)
(cherry picked from commit 6f0c386567)
(cherry picked from commit 784a5857b7)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
2026-07-11 19:59:07 -07:00

2540 lines
111 KiB
Swift

import Foundation
import Network
import OpenClawChatUI
import os
import Testing
import UIKit
@testable import OpenClaw
@testable import OpenClawKit
@discardableResult
private func saveActiveManualGateway(
host: String,
port: Int,
useTLS: Bool,
stableID: String) -> Bool
{
let entry = GatewaySettingsStore.GatewayRegistryEntry(
stableID: stableID,
kind: .manual,
name: "\(host):\(port)",
host: host,
port: port,
useTLS: useTLS,
lastConnectedAtMs: nil)
return GatewaySettingsStore.upsertGatewayRegistryEntry(entry, activate: true)
}
private struct GatewayRegistryTestIsolation {
private static let service = GatewaySettingsStore._testGatewayService
private static let registryAccount = "gateway-registry"
private static let legacyAccount = "lastConnection"
private static let legacyDefaultsKeys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
private let previousRegistry: String?
private let previousLegacyConnection: String?
private let previousDefaults: [String: Any?]
init() {
gatewayPersistenceTestSemaphore.wait()
self.previousRegistry = KeychainStore.loadString(
service: Self.service,
account: Self.registryAccount)
self.previousLegacyConnection = KeychainStore.loadString(
service: Self.service,
account: Self.legacyAccount)
self.previousDefaults = Dictionary(uniqueKeysWithValues: Self.legacyDefaultsKeys.map { key in
(key, UserDefaults.standard.object(forKey: key))
})
_ = KeychainStore.delete(service: Self.service, account: Self.registryAccount)
_ = KeychainStore.delete(service: Self.service, account: Self.legacyAccount)
for key in Self.legacyDefaultsKeys {
UserDefaults.standard.removeObject(forKey: key)
}
}
func restore() {
if let previousRegistry {
_ = KeychainStore.saveString(
previousRegistry,
service: Self.service,
account: Self.registryAccount)
} else {
_ = KeychainStore.delete(service: Self.service, account: Self.registryAccount)
}
if let previousLegacyConnection {
_ = KeychainStore.saveString(
previousLegacyConnection,
service: Self.service,
account: Self.legacyAccount)
} else {
_ = KeychainStore.delete(service: Self.service, account: Self.legacyAccount)
}
for (key, value) in self.previousDefaults {
if let value {
UserDefaults.standard.set(value, forKey: key)
} else {
UserDefaults.standard.removeObject(forKey: key)
}
}
gatewayPersistenceTestSemaphore.signal()
}
}
@MainActor
private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) async {
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while appModel.activeGatewayConnectConfig?.effectiveStableID != stableID,
ContinuousClock().now < deadline
{
await Task.yield()
}
}
@Suite(.serialized) struct GatewayConnectionControllerTests {
@Test @MainActor func `chat owner survives reconnect while session refresh identity changes`() {
let appModel = NodeAppModel()
let disconnectedOwner = appModel.chatViewModelOwnerID
let disconnectedRefreshIdentity = appModel.chatViewModelIdentityID
appModel._test_setOperatorConnected(true)
#expect(appModel.chatViewModelOwnerID == disconnectedOwner)
#expect(appModel.chatViewModelIdentityID != disconnectedRefreshIdentity)
appModel._test_setOperatorConnected(false)
#expect(appModel.chatViewModelOwnerID == disconnectedOwner)
#expect(appModel.chatViewModelIdentityID == disconnectedRefreshIdentity)
}
@Test func `direct push registration dedupe includes gateway identity`() {
#expect(!NodeAppModel.shouldPublishDirectAPNsRegistration(
token: "device-token",
gatewayStableID: "gateway-a",
lastToken: "device-token",
lastGatewayStableID: "gateway-a"))
#expect(NodeAppModel.shouldPublishDirectAPNsRegistration(
token: "device-token",
gatewayStableID: "gateway-b",
lastToken: "device-token",
lastGatewayStableID: "gateway-a"))
#expect(NodeAppModel.shouldPublishDirectAPNsRegistration(
token: "device-token",
gatewayStableID: "gateway-\u{00E9}",
lastToken: "device-token",
lastGatewayStableID: "gateway-e\u{0301}"))
}
@Test func `push relay identity preserves exact opaque gateway bytes`() throws {
for deviceID in ["\u{0085}gateway-\u{00E9}", " gateway", "gateway\u{FEFF}"] {
let identity = try NodeAppModel._test_decodePushRelayGatewayIdentity(
#"{"deviceId":"\#(deviceID)","publicKey":"public-key"}"#)
#expect(Array(identity.deviceId.utf8) == Array(deviceID.utf8))
}
}
@Test @MainActor func `resolved display name sets default when missing`() {
let defaults = UserDefaults.standard
let displayKey = "node.displayName"
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
#expect(!resolved.isEmpty)
#expect(defaults.string(forKey: displayKey) == resolved)
}
}
@Test @MainActor func `current caps reflect toggles`() {
withUserDefaults([
"node.instanceId": "ios-test",
"node.displayName": "Test Node",
"camera.enabled": true,
"location.enabledMode": OpenClawLocationMode.always.rawValue,
VoiceWakePreferences.enabledKey: true,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let caps = Set(controller._test_currentCaps())
#expect(caps.contains(OpenClawCapability.canvas.rawValue))
#expect(caps.contains(OpenClawCapability.screen.rawValue))
#expect(caps.contains(OpenClawCapability.camera.rawValue))
#expect(caps.contains(OpenClawCapability.location.rawValue))
#expect(caps.contains(OpenClawCapability.voiceWake.rawValue))
#expect(caps.contains(OpenClawCapability.talk.rawValue))
}
}
@Test @MainActor func `current commands include location when enabled`() {
withUserDefaults([
"node.instanceId": "ios-test",
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
}
}
@Test @MainActor func `location permission requires global services and app authorization`() {
#expect(GatewayConnectionController._test_isLocationAvailable(
servicesEnabled: true,
status: .authorizedWhenInUse))
#expect(GatewayConnectionController._test_isLocationAvailable(
servicesEnabled: true,
status: .authorizedAlways))
#expect(!GatewayConnectionController._test_isLocationAvailable(
servicesEnabled: false,
status: .authorizedAlways))
#expect(!GatewayConnectionController._test_isLocationAvailable(
servicesEnabled: true,
status: .denied))
}
@Test @MainActor func `current commands exclude dangerous system exec commands`() {
withUserDefaults([
"node.instanceId": "ios-test",
"camera.enabled": true,
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
// iOS should expose notify, but not host shell/exec-approval commands.
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
}
}
@Test @MainActor func `operator connect options only request approval scope when enabled`() {
let appModel = NodeAppModel()
let withoutApprovalScope = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS",
includeApprovalScope: false)
let withApprovalScope = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS",
includeApprovalScope: true)
let withAdminScope = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS",
includeAdminScope: true,
includeApprovalScope: false)
#expect(withoutApprovalScope.role == "operator")
#expect(!withoutApprovalScope.scopes.contains("operator.admin"))
#expect(withoutApprovalScope.scopes.contains("operator.read"))
#expect(withoutApprovalScope.scopes.contains("operator.write"))
#expect(!withoutApprovalScope.scopes.contains("operator.approvals"))
#expect(withoutApprovalScope.scopes.contains("operator.talk.secrets"))
#expect(!withoutApprovalScope.scopesAreExplicit)
#expect(withApprovalScope.scopes.contains("operator.approvals"))
#expect(withAdminScope.scopes.contains("operator.admin"))
}
@Test @MainActor func `operator talk permission upgrade uses explicit least privilege scopes`() {
let appModel = NodeAppModel()
let options = appModel._test_makeOperatorConnectOptions(
clientId: "openclaw-ios",
displayName: "OpenClaw iOS",
includeApprovalScope: false,
forceExplicitScopes: true)
#expect(options.scopesAreExplicit)
#expect(!options.scopes.contains("operator.admin"))
#expect(!options.scopes.contains("operator.approvals"))
#expect(options.scopes.contains("operator.read"))
#expect(options.scopes.contains("operator.write"))
#expect(options.scopes.contains("operator.talk.secrets"))
}
@Test func `operator admin scope requests only when shared auth or already granted`() {
#expect(
!NodeAppModel._test_shouldRequestOperatorAdminScope(
token: nil,
password: nil,
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"]))
#expect(
NodeAppModel._test_shouldRequestOperatorAdminScope(
token: nil,
password: nil,
storedOperatorScopes: ["operator.admin"]))
#expect(
NodeAppModel._test_shouldRequestOperatorAdminScope(
token: "shared-token",
password: nil,
storedOperatorScopes: []))
#expect(
NodeAppModel._test_shouldRequestOperatorAdminScope(
token: nil,
password: "shared-password",
storedOperatorScopes: []))
#expect(
!NodeAppModel._test_shouldRequestOperatorAdminScope(
token: "shared-token",
password: nil,
storedOperatorScopes: [],
forceTalkPermissionUpgradeRequest: true))
}
@Test func `stored device token scope gap uses gateway scope compatibility`() {
#expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.read", "operator.write", "operator.talk.secrets"],
storedToken: "stored-device-token",
storedScopes: ["operator.admin"]))
#expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.read"],
storedToken: "stored-device-token",
storedScopes: []))
#expect(GatewayChannelActor._test_requestedScopesExceedStoredToken(
role: "operator",
requestedScopes: ["operator.admin"],
storedToken: "stored-device-token",
storedScopes: ["operator.read"]))
}
@Test func `operator approval scope requests stay backward compatible`() {
#expect(
!NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: nil,
password: nil,
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"]))
#expect(
NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: nil,
password: nil,
storedOperatorScopes: [
"operator.approvals",
"operator.read",
"operator.write",
"operator.talk.secrets",
]))
#expect(
NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: "shared-token",
password: nil,
storedOperatorScopes: []))
#expect(
!NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: "shared-token",
password: nil,
storedOperatorScopes: [],
forceTalkPermissionUpgradeRequest: true))
#expect(
NodeAppModel._test_shouldRequestOperatorApprovalScope(
token: nil,
password: nil,
storedOperatorScopes: ["operator.approvals"],
forceTalkPermissionUpgradeRequest: true))
}
@Test @MainActor func `operator pairing problem preserves primary gateway connection state`() {
let appModel = NodeAppModel()
appModel._test_setGatewayConnected(true)
appModel.gatewayServerName = "gateway.example.com"
appModel.gatewayRemoteAddress = "127.0.0.1:53380"
let problem = GatewayConnectionProblem(
kind: .pairingScopeUpgradeRequired,
owner: .gateway,
title: "Additional permissions required",
message: "Approve the requested permissions on the gateway.",
requestId: "req-admin",
retryable: false,
pauseReconnect: true)
appModel._test_applyOperatorGatewayConnectionProblem(problem)
#expect(appModel._test_isGatewayConnected())
#expect(appModel.gatewayServerName == "gateway.example.com")
#expect(appModel.gatewayRemoteAddress == "127.0.0.1:53380")
#expect(appModel.lastGatewayProblem == problem)
#expect(appModel.gatewayPairingPaused)
#expect(appModel.gatewayPairingRequestId == "req-admin")
appModel._test_clearGatewayConnectionProblem()
#expect(appModel.lastGatewayProblem == problem)
#expect(appModel.gatewayPairingPaused)
#expect(appModel.gatewayPairingRequestId == "req-admin")
appModel._test_clearOperatorGatewayConnectionProblemIfCurrent()
#expect(appModel._test_isGatewayConnected())
#expect(appModel.gatewayServerName == "gateway.example.com")
#expect(appModel.lastGatewayProblem == nil)
#expect(!appModel.gatewayPairingPaused)
#expect(appModel.gatewayPairingRequestId == nil)
#expect(appModel.gatewayStatusText == "Connected")
}
@Test func `gateway connect config matches equivalent inputs`() {
let lhs = Self.makeGatewayConnectConfig()
let rhs = GatewayConnectConfig(
url: lhs.url,
stableID: lhs.stableID,
tls: lhs.tls,
token: lhs.token,
bootstrapToken: lhs.bootstrapToken,
password: lhs.password,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["canvas", "screen"],
commands: ["location.get", "notify"],
permissions: ["screen": true],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone"))
#expect(lhs.hasSameConnectionInputs(as: rhs))
}
@Test func `gateway connect config keeps stable owner bytes exact`() {
let composedID = "gateway-\u{00E9}"
let decomposedID = "gateway-e\u{0301}"
let boundaryID = "\u{0085}gateway"
let composed = Self.makeGatewayConnectConfig(stableID: composedID)
let decomposed = Self.makeGatewayConnectConfig(stableID: decomposedID)
let boundary = Self.makeGatewayConnectConfig(stableID: boundaryID)
#expect(composedID == decomposedID)
#expect(Array(composed.effectiveStableID.utf8) == Array(composedID.utf8))
#expect(Array(boundary.effectiveStableID.utf8) == Array(boundaryID.utf8))
#expect(!composed.hasSameConnectionInputs(as: decomposed))
var composedOptions = composed.nodeOptions
composedOptions.deviceAuthGatewayID = composedID
var decomposedOptions = composed.nodeOptions
decomposedOptions.deviceAuthGatewayID = decomposedID
let composedAuthOwner = GatewayConnectConfig(
url: composed.url,
stableID: "shared-route",
tls: composed.tls,
token: composed.token,
bootstrapToken: composed.bootstrapToken,
password: composed.password,
nodeOptions: composedOptions)
let decomposedAuthOwner = GatewayConnectConfig(
url: composed.url,
stableID: "shared-route",
tls: composed.tls,
token: composed.token,
bootstrapToken: composed.bootstrapToken,
password: composed.password,
nodeOptions: decomposedOptions)
#expect(!composedAuthOwner.hasSameConnectionInputs(as: decomposedAuthOwner))
}
@Test @MainActor func `gateway reconnect options stay scoped to exact owner bytes`() {
let composedID = "gateway-\u{00E9}"
let decomposedID = "gateway-e\u{0301}"
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let config = Self.makeGatewayConnectConfig(stableID: composedID)
appModel.applyGatewayConnectConfig(config)
var fallback = config.nodeOptions
fallback.clientId = "fallback-client"
let selected = appModel._test_currentGatewayReconnectOptions(
stableID: decomposedID,
fallback: fallback)
#expect(selected.clientId == "fallback-client")
}
@Test func `setup auth override is scoped to scanned endpoint`() {
let link = GatewayConnectDeepLink(
host: "first.gateway.example.com",
port: 443,
tls: true,
bootstrapToken: "bootstrap-token",
token: "source-token",
password: "source-password")
let pending = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link).manualAuthOverride
let firstStableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: link.host,
port: link.port)
let secondStableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: "second.gateway.example.com",
port: 443)
let first = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: "source-token",
pendingOverride: pending,
password: "source-password",
targetStableID: firstStableID)
let second = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: "source-token",
pendingOverride: pending,
password: "source-password",
targetStableID: secondStableID)
let edited = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: "replacement-token",
pendingOverride: pending,
password: "source-password",
targetStableID: secondStableID)
let ordinary = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: "manual-token",
pendingOverride: nil,
password: nil,
targetStableID: secondStableID)
#expect(first?.token == "source-token")
#expect(first?.bootstrapToken == "bootstrap-token")
#expect(first?.password == "source-password")
#expect(first?.targetStableID == firstStableID)
#expect(first?.suppressStoredDeviceAuth == true)
#expect(second?.token == nil)
#expect(second?.bootstrapToken == nil)
#expect(second?.password == nil)
#expect(second?.targetStableID == secondStableID)
#expect(second?.suppressStoredDeviceAuth == true)
#expect(edited?.token == "replacement-token")
#expect(edited?.password == nil)
#expect(ordinary?.suppressStoredDeviceAuth == false)
}
@Test func `persisted setup auth stays scoped after view recreation`() throws {
let instanceID = "setup-auth-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let firstStableID = "manual|first.gateway.example.com|443"
let secondStableID = "manual|second.gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "source-token",
bootstrapToken: "source-bootstrap-token",
password: "source-password",
gatewayStableID: firstStableID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
let relaunchedOverride = try #require(
GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceID,
targetStableID: firstStableID))
let sameTargetRetryOverride = try #require(
GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceID,
targetStableID: firstStableID))
#expect(sameTargetRetryOverride.bootstrapToken == "source-bootstrap-token")
#expect(GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceID,
targetStableID: secondStableID) == nil)
let secondGatewayAuth = try #require(
GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: "source-token",
pendingOverride: relaunchedOverride,
password: "source-password",
targetStableID: secondStableID))
GatewaySettingsStore.saveGatewayCredentials(
token: secondGatewayAuth.token,
bootstrapToken: secondGatewayAuth.bootstrapToken,
password: secondGatewayAuth.password,
gatewayStableID: secondStableID,
suppressStoredDeviceAuth: secondGatewayAuth.suppressStoredDeviceAuth,
instanceId: instanceID)
#expect(secondGatewayAuth.token == nil)
#expect(secondGatewayAuth.bootstrapToken == nil)
#expect(secondGatewayAuth.password == nil)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: firstStableID).bootstrapToken == "source-bootstrap-token")
let persistedCredentiallessHandoff = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondStableID)
#expect(!persistedCredentiallessHandoff.hasCredentials)
#expect(persistedCredentiallessHandoff.suppressStoredDeviceAuth)
let nextRelaunchOverride = try #require(
GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceID,
targetStableID: secondStableID))
#expect(nextRelaunchOverride.targetStableID == secondStableID)
#expect(nextRelaunchOverride.suppressStoredDeviceAuth)
}
@Test @MainActor func `empty setup auth does not reuse stored gateway credentials`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
let defaults = UserDefaults.standard
let previousInstanceID = defaults.object(forKey: "node.instanceId")
let instanceID = "ios-test-\(UUID().uuidString)"
defaults.set(instanceID, forKey: "node.instanceId")
GatewaySettingsStore.saveGatewayCredentials(
token: "stored-token",
bootstrapToken: nil,
password: "stored-password",
gatewayStableID: "manual|stored.example.com|443",
suppressStoredDeviceAuth: false,
instanceId: instanceID)
defer {
GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID)
if let previousInstanceID {
defaults.set(previousInstanceID, forKey: "node.instanceId")
} else {
defaults.removeObject(forKey: "node.instanceId")
}
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let link = GatewayConnectDeepLink(
host: "192.168.1.41",
port: 18789,
tls: false,
bootstrapToken: nil,
token: nil,
password: nil)
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
await controller.connectManual(
host: link.host,
port: link.port,
useTLS: link.tls,
authOverride: setupAuth.manualAuthOverride)
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while appModel.activeGatewayConnectConfig == nil, ContinuousClock().now < deadline {
await Task.yield()
}
#expect(appModel.activeGatewayConnectConfig != nil)
#expect(appModel.activeGatewayConnectConfig?.token == nil)
#expect(appModel.activeGatewayConnectConfig?.bootstrapToken == nil)
#expect(appModel.activeGatewayConnectConfig?.password == nil)
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == false)
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == setupAuth.targetStableID)
}
@Test @MainActor func `legacy auth preserves proven relay credentials and otherwise requires full re-pair`() throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
let defaults = UserDefaults.standard
let previousInstanceID = defaults.string(forKey: "node.instanceId")
let instanceID = "legacy-relay-\(UUID().uuidString)"
let gatewayService = GatewaySettingsStore._testGatewayService
let lastConnectionAccount = "gateway-registry"
let previousLastConnection = KeychainStore.loadString(
service: gatewayService,
account: lastConnectionAccount)
let previousRelay = ShareGatewayRelaySettings.loadConfig()
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defaults.set(instanceID, forKey: "node.instanceId")
defer {
GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID)
if let previousInstanceID {
defaults.set(previousInstanceID, forKey: "node.instanceId")
} else {
defaults.removeObject(forKey: "node.instanceId")
}
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
if let previousLastConnection {
_ = KeychainStore.saveString(
previousLastConnection,
service: gatewayService,
account: lastConnectionAccount)
} else {
_ = KeychainStore.delete(service: gatewayService, account: lastConnectionAccount)
}
if let previousRelay {
ShareGatewayRelaySettings.saveConfig(previousRelay)
} else {
ShareGatewayRelaySettings.clearConfig()
}
try? FileManager.default.removeItem(at: tempDir)
}
let stableID = "manual|gateway.example.com|443"
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "legacy-primary-token")
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "operator",
token: "legacy-operator-token")
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "legacy-share-token",
profile: .shareExtension)
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest(
"unproven-field-token",
instanceId: instanceID)
saveActiveManualGateway(
host: "gateway.example.com",
port: 443,
useTLS: true,
stableID: stableID)
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
gatewayURLString: "wss://gateway.example.com",
token: "proven-relay-token",
password: "proven-relay-password",
sessionKey: "main"))
let extensionConfig = try #require(ShareGatewayRelaySettings.loadConfigDiscardingUnscopedDeviceAuth())
#expect(extensionConfig.gatewayStableID == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: shareIdentity.deviceId,
role: "node",
profile: .shareExtension) == nil)
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "ambiguous-share-token",
profile: .shareExtension)
_ = GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
#expect(DeviceAuthStore.loadToken(
deviceId: primaryIdentity.deviceId,
role: "node",
gatewayID: stableID)?.token == "legacy-primary-token")
#expect(DeviceAuthStore.loadToken(
deviceId: primaryIdentity.deviceId,
role: "operator",
gatewayID: stableID) == nil)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "operator") == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: shareIdentity.deviceId,
role: "node",
gatewayID: stableID,
profile: .shareExtension) == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: shareIdentity.deviceId,
role: "node",
profile: .shareExtension) == nil)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node") == nil)
#expect(ShareGatewayRelaySettings.loadConfig()?.gatewayStableID == stableID)
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: stableID)
#expect(credentials.token == "proven-relay-token")
#expect(credentials.password == "proven-relay-password")
#expect(!credentials.suppressStoredDeviceAuth)
#expect(KeychainStore.loadString(
service: gatewayService,
account: "gateway-token.\(instanceID)") == nil)
let rePairStableID = "manual|pair-again.example.com|443"
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "legacy-node-without-shared-auth")
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "operator",
token: "legacy-operator-without-shared-auth")
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest(
"unproven-field-token",
instanceId: instanceID)
saveActiveManualGateway(
host: "pair-again.example.com",
port: 443,
useTLS: true,
stableID: rePairStableID)
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
gatewayURLString: "wss://pair-again.example.com",
token: nil,
password: nil,
sessionKey: "main"))
_ = GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
#expect(DeviceAuthStore.loadToken(
deviceId: primaryIdentity.deviceId,
role: "node",
gatewayID: rePairStableID) == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: primaryIdentity.deviceId,
role: "operator",
gatewayID: rePairStableID) == nil)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node") == nil)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "operator") == nil)
}
@Test @MainActor func `successful setup handoff enables target scoped auth`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let identity = DeviceIdentityStore.loadOrCreate()
let previousStableID = "manual|previous.gateway.example.com|443"
let stableID = "manual|new.gateway.example.com|443"
let instanceID = "bootstrap-handoff-\(UUID().uuidString)"
let previousInstanceID = UserDefaults.standard.string(forKey: "node.instanceId")
UserDefaults.standard.set(instanceID, forKey: "node.instanceId")
defer {
GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID)
if let previousInstanceID {
UserDefaults.standard.set(previousInstanceID, forKey: "node.instanceId")
} else {
UserDefaults.standard.removeObject(forKey: "node.instanceId")
}
}
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "node",
token: "previous-node-token",
gatewayID: previousStableID)
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: "previous-operator-token",
gatewayID: previousStableID)
var nodeOptions = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone",
allowStoredDeviceAuth: false,
deviceAuthGatewayID: stableID)
let config = try GatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: stableID,
tls: nil,
token: nil,
bootstrapToken: "one-time-bootstrap",
password: nil,
nodeOptions: nodeOptions)
GatewaySettingsStore.saveGatewayCredentials(
token: nil,
bootstrapToken: "one-time-bootstrap",
password: nil,
gatewayStableID: stableID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
appModel.applyGatewayConnectConfig(config)
let emptyIssuanceOptions = appModel._test_completeSuccessfulGatewayAuthHandoff(
issuedRoles: [],
nodeOptions: nodeOptions)
let operatorOnlyOptions = appModel._test_completeSuccessfulGatewayAuthHandoff(
issuedRoles: ["operator"],
nodeOptions: nodeOptions)
let nodeOnlyOptions = appModel._test_completeSuccessfulGatewayAuthHandoff(
issuedRoles: ["node"],
nodeOptions: nodeOptions)
#expect(emptyIssuanceOptions == nil)
#expect(operatorOnlyOptions == nil)
#expect(nodeOnlyOptions == nil)
#expect(appModel.activeGatewayConnectConfig?.bootstrapToken == "one-time-bootstrap")
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(
instanceId: instanceID,
gatewayStableID: stableID) != nil)
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "node",
token: "new-node-token",
gatewayID: stableID)
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: "new-operator-token",
gatewayID: stableID)
let bootstrapOptions = nodeOptions
appModel._test_setGatewayLoopTasks(node: nil, operator: Task {})
nodeOptions = try #require(appModel._test_completeSuccessfulGatewayAuthHandoff(
issuedRoles: ["node", "operator"],
nodeOptions: nodeOptions))
#expect(nodeOptions.allowStoredDeviceAuth)
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true)
#expect(appModel.activeGatewayConnectConfig?.bootstrapToken == nil)
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(
instanceId: instanceID,
gatewayStableID: stableID) == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: stableID)?.token == "new-node-token")
#expect(DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: stableID)?.token == "new-operator-token")
#expect(DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: previousStableID)?.token == "previous-node-token")
#expect(appModel._test_hasGatewayLoopTasks().operator)
#expect(appModel._test_currentGatewayReconnectOptions(
stableID: stableID,
fallback: bootstrapOptions).allowStoredDeviceAuth)
}
@Test @MainActor func `bootstrap pairing clears only the target gateway`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
let gatewayA = "manual|gateway-a-\(UUID().uuidString)|443"
let gatewayB = "manual|gateway-b-\(UUID().uuidString)|443"
defer {
GatewayTLSStore.clearFingerprint(stableID: gatewayA)
GatewayTLSStore.clearFingerprint(stableID: gatewayB)
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
GatewayTLSStore.saveFingerprint("fingerprint-a", stableID: gatewayA)
GatewayTLSStore.saveFingerprint("fingerprint-b", stableID: gatewayB)
let gatewayAOwner = GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayA)
let gatewayBOwner = GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayB)
for (routeID, ownerID) in [(gatewayA, gatewayAOwner), (gatewayB, gatewayBOwner)] {
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-\(routeID)",
gatewayID: ownerID)
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "share-\(routeID)",
gatewayID: ownerID,
profile: .shareExtension)
}
let appModel = NodeAppModel()
await GatewayOnboardingReset.prepareForBootstrapPairing(
appModel: appModel,
instanceId: "",
gatewayStableID: gatewayB,
disconnectGateway: false)
#expect(DeviceAuthStore.loadToken(
deviceId: primaryIdentity.deviceId,
role: "node",
gatewayID: gatewayAOwner) != nil)
#expect(DeviceAuthStore.loadToken(
deviceId: primaryIdentity.deviceId,
role: "node",
gatewayID: gatewayBOwner) == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: shareIdentity.deviceId,
role: "node",
gatewayID: gatewayAOwner,
profile: .shareExtension) != nil)
#expect(DeviceAuthStore.loadToken(
deviceId: shareIdentity.deviceId,
role: "node",
gatewayID: gatewayBOwner,
profile: .shareExtension) == nil)
#expect(GatewayTLSStore.loadFingerprint(stableID: gatewayA) == "fingerprint-a")
#expect(GatewayTLSStore.loadFingerprint(stableID: gatewayB) == nil)
}
@Test @MainActor func `explicit auth starts operator loop while stored auth is disabled`() throws {
let stableID = "manual|gateway.example.com|443"
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let config = try GatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: stableID,
tls: nil,
token: "shared-token",
bootstrapToken: nil,
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone",
allowStoredDeviceAuth: false,
deviceAuthGatewayID: stableID))
appModel.applyGatewayConnectConfig(config)
#expect(appModel._test_hasGatewayLoopTasks().node)
#expect(appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func `applying different gateway config reconnects active tasks`() throws {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let first = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: "manual|first.gateway.example.com|443")
let second = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:2")),
stableID: "manual|second.gateway.example.com|443")
appModel.applyGatewayConnectConfig(first)
appModel.talkMode.updateGatewayConnected(true)
appModel.applyGatewayConnectConfig(second)
#expect(appModel.connectedGatewayID == second.stableID)
#expect(!appModel.talkMode.isGatewayConnected)
}
@Test @MainActor func `forced reconnect reset clears active gateway loop tasks`() async {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig())
#expect(appModel._test_hasGatewayLoopTasks().node)
#expect(appModel._test_hasGatewayLoopTasks().operator)
await appModel.resetGatewaySessionsForForcedReconnect()
#expect(!appModel._test_hasGatewayLoopTasks().node)
#expect(!appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func `forced reconnect reset waits for canceled loop cleanup`() async {
var cleanupStarted = false
var releaseCleanup: CheckedContinuation<Void, Never>?
var resetFinished = false
let appModel = NodeAppModel()
let loopTask = Task { @MainActor in
while !Task.isCancelled {
await Task.yield()
}
cleanupStarted = true
await withCheckedContinuation { continuation in
releaseCleanup = continuation
}
}
appModel._test_setGatewayLoopTasks(node: loopTask)
defer {
releaseCleanup?.resume()
appModel.disconnectGateway()
}
let resetTask = Task { @MainActor in
await appModel.resetGatewaySessionsForForcedReconnect()
resetFinished = true
}
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while !cleanupStarted, ContinuousClock().now < deadline {
await Task.yield()
}
#expect(cleanupStarted)
#expect(appModel.hasGatewaySessionResetInFlight)
#expect(!resetFinished)
releaseCleanup?.resume()
releaseCleanup = nil
await resetTask.value
#expect(resetFinished)
#expect(!appModel.hasGatewaySessionResetInFlight)
}
@Test @MainActor func `manual disconnect chains after existing reset to own new loops`() async {
let resetRelease = AsyncStream<Void>.makeStream()
let appModel = NodeAppModel()
defer {
resetRelease.continuation.finish()
appModel._test_setGatewaySessionResetTask(nil)
appModel.disconnectGateway()
}
let existingReset = Task {
for await _ in resetRelease.stream {
return
}
}
appModel._test_setGatewaySessionResetTask(existingReset)
appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig())
#expect(appModel._test_hasGatewayLoopTasks().node)
#expect(appModel._test_hasGatewayLoopTasks().operator)
appModel.disconnectGateway()
resetRelease.continuation.yield()
resetRelease.continuation.finish()
await appModel.waitForGatewaySessionResetIfNeeded()
#expect(appModel.activeGatewayConnectConfig == nil)
#expect(!appModel._test_hasGatewayLoopTasks().node)
#expect(!appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func `target switch reset clears previous reconnect route`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let defaults = UserDefaults.standard
let reconnectDefaults: [String: Any?] = [
"gateway.autoconnect": true,
"gateway.manual.enabled": true,
"gateway.manual.host": "previous.gateway.invalid",
"gateway.manual.port": 443,
"gateway.manual.tls": true,
]
var reconnectDefaultsSnapshot: [String: Any?] = [:]
for key in reconnectDefaults.keys {
reconnectDefaultsSnapshot[key] = defaults.object(forKey: key)
}
let gatewayService = GatewaySettingsStore._testGatewayService
let lastConnectionAccount = "gateway-registry"
let priorLastConnection = KeychainStore.loadString(
service: gatewayService,
account: lastConnectionAccount)
let priorRelayConfig = ShareGatewayRelaySettings.loadConfig()
defer {
if let priorRelayConfig {
ShareGatewayRelaySettings.saveConfig(priorRelayConfig)
} else {
ShareGatewayRelaySettings.clearConfig()
}
}
defer {
for (key, value) in reconnectDefaultsSnapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
if let priorLastConnection {
_ = KeychainStore.saveString(
priorLastConnection,
service: gatewayService,
account: lastConnectionAccount)
} else {
_ = KeychainStore.delete(service: gatewayService, account: lastConnectionAccount)
}
}
for (key, value) in reconnectDefaults {
defaults.set(value, forKey: key)
}
saveActiveManualGateway(
host: "previous.gateway.invalid",
port: 443,
useTLS: true,
stableID: "manual|previous.gateway.invalid|443")
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
gatewayURLString: "wss://previous.gateway.invalid",
token: "previous-token",
password: nil,
sessionKey: "main"))
appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig())
await appModel.resetGatewaySessionsForTargetSwitch()
#expect(!appModel.gatewayAutoReconnectEnabled)
#expect(!defaults.bool(forKey: "gateway.autoconnect"))
#expect(appModel.activeGatewayConnectConfig == nil)
#expect(appModel.gatewayServerName == nil)
#expect(!appModel._test_hasGatewayLoopTasks().node)
#expect(!appModel._test_hasGatewayLoopTasks().operator)
#expect(!appModel._test_hasChatSessionRoutingRestoreTask())
#expect(ShareGatewayRelaySettings.loadConfig() == nil)
let relaunchedModel = NodeAppModel()
defer { relaunchedModel.disconnectGateway() }
let relaunchedController = GatewayConnectionController(
appModel: relaunchedModel,
startDiscovery: false)
relaunchedController._test_triggerAutoConnect()
#expect(!relaunchedController._test_didAutoConnect())
#expect(relaunchedModel.activeGatewayConnectConfig == nil)
}
@Test @MainActor func `target switch reset reasserts persisted reconnect pause after teardown`() async {
let defaults = UserDefaults.standard
let priorAutoConnect = defaults.object(forKey: "gateway.autoconnect")
defer {
if let priorAutoConnect {
defaults.set(priorAutoConnect, forKey: "gateway.autoconnect")
} else {
defaults.removeObject(forKey: "gateway.autoconnect")
}
}
defaults.set(true, forKey: "gateway.autoconnect")
let teardownRelease = AsyncStream<Void>.makeStream()
let appModel = NodeAppModel()
defer {
appModel._test_setGatewaySessionResetTask(nil)
appModel.disconnectGateway()
}
let staleTeardownTask = Task {
for await _ in teardownRelease.stream {
defaults.set(true, forKey: "gateway.autoconnect")
return
}
}
appModel._test_setGatewaySessionResetTask(staleTeardownTask)
let targetResetTask = Task {
await appModel.resetGatewaySessionsForTargetSwitch()
}
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while defaults.bool(forKey: "gateway.autoconnect"), ContinuousClock().now < deadline {
await Task.yield()
}
#expect(!defaults.bool(forKey: "gateway.autoconnect"))
teardownRelease.continuation.yield()
teardownRelease.continuation.finish()
await targetResetTask.value
#expect(!defaults.bool(forKey: "gateway.autoconnect"))
}
@Test @MainActor func `newer gateway connect generation rejects queued config`() throws {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let staleGeneration = appModel.beginGatewayConnectAttempt()
let currentGeneration = appModel.beginGatewayConnectAttempt()
let staleConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://stale.gateway.invalid")),
stableID: "manual|stale.gateway.invalid|443")
let currentConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: "manual|current.gateway.invalid|443")
appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration)
#expect(appModel.activeGatewayConnectConfig == nil)
appModel.applyGatewayConnectConfig(currentConfig, expectedGeneration: currentGeneration)
#expect(appModel.activeGatewayConnectConfig?.stableID == currentConfig.stableID)
}
@Test @MainActor func `direct gateway apply invalidates older queued config`() throws {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let staleGeneration = appModel.beginGatewayConnectAttempt()
let staleConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://stale.gateway.invalid")),
stableID: "manual|stale.gateway.invalid|443")
let currentConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: "manual|current.gateway.invalid|443")
appModel.applyGatewayConnectConfig(currentConfig)
appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration)
#expect(appModel.activeGatewayConnectConfig?.stableID == currentConfig.stableID)
}
@Test @MainActor func `newer explicit connect immediately invalidates queued config`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let host = "new-target.gateway.invalid"
let stableID = "manual|\(host.lowercased())|443"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.clearFingerprint(stableID: stableID)
let probeStarted = AsyncStream<Void>.makeStream()
let probeResults = AsyncStream<GatewayTLSFingerprintProbeResult>.makeStream()
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in
probeStarted.continuation.yield()
for await result in probeResults.stream {
return result
}
return .failure(.certificateUnavailable)
})
let staleGeneration = appModel.beginGatewayConnectAttempt()
let staleConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://old-target.gateway.invalid")),
stableID: "manual|old-target.gateway.invalid|443")
let duringResolutionConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://resolution-window.gateway.invalid")),
stableID: "manual|resolution-window.gateway.invalid|443")
var startedIterator = probeStarted.stream.makeAsyncIterator()
let connectTask = Task {
await controller.connectManual(host: host, port: 443, useTLS: true)
}
_ = await startedIterator.next()
appModel.applyGatewayConnectConfig(staleConfig, expectedGeneration: staleGeneration)
#expect(appModel.activeGatewayConnectConfig == nil)
let duringResolutionGeneration = appModel.gatewayConnectGeneration
probeResults.continuation.yield(.fingerprint("new-target-fingerprint"))
probeResults.continuation.finish()
await connectTask.value
await controller.acceptPendingTrustPrompt()
appModel.applyGatewayConnectConfig(
duringResolutionConfig,
expectedGeneration: duringResolutionGeneration)
#expect(appModel.activeGatewayConnectConfig?.stableID != duringResolutionConfig.stableID)
}
@Test @MainActor func `trusted certificate keeps device auth route scoped`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let host = "127.0.0.1"
let stableID = "manual|\(host)|1"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.clearFingerprint(stableID: stableID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("route-independent-fingerprint") })
await controller.connectManual(host: host, port: 1, useTLS: true)
await controller.acceptPendingTrustPrompt()
for _ in 0..<100 where appModel.activeGatewayConnectConfig == nil {
try await Task.sleep(for: .milliseconds(10))
}
#expect(appModel.activeGatewayConnectConfig?.stableID == stableID)
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == stableID)
}
@Test @MainActor func `discovered connect preserves exact device auth owner bytes`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let stableID = "\u{0085}gateway-e\u{0301}"
let endpoint: NWEndpoint = .service(
name: "Exact Owner",
type: "_openclaw-gw._tcp",
domain: "local.",
interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Exact Owner",
endpoint: endpoint,
stableID: stableID,
debugID: "exact-owner",
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: nil,
cliPath: nil)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let persistedOwnerBytes = OSAllocatedUnfairLock<[UInt8]?>(initialState: nil)
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("exact-owner-fingerprint") },
serviceEndpointResolver: { _ in (host: "127.0.0.1", port: 1) },
persistTLSFingerprint: { _, owner in
persistedOwnerBytes.withLock { $0 = Array(owner.utf8) }
return true
})
#expect(await controller.connectWithDiagnostics(gateway) == nil)
await controller.acceptPendingTrustPrompt()
for _ in 0..<100 where appModel.activeGatewayConnectConfig == nil {
try await Task.sleep(for: .milliseconds(10))
}
#expect(persistedOwnerBytes.withLock { $0 } == Array(stableID.utf8))
#expect(appModel.activeGatewayConnectConfig.map { Array($0.stableID.utf8) } == Array(stableID.utf8))
#expect(appModel.activeGatewayConnectConfig
.flatMap(\.nodeOptions.deviceAuthGatewayID)
.map { Array($0.utf8) } == Array(stableID.utf8))
}
@Test @MainActor func `first trust aborts when certificate pin is not durable`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let host = "127.0.0.1"
let stableID = "manual|\(host)|2"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.clearFingerprint(stableID: stableID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("unpersisted-fingerprint") },
persistTLSFingerprint: { _, _ in false })
await controller.connectManual(host: host, port: 2, useTLS: true)
await controller.acceptPendingTrustPrompt()
#expect(controller.pendingTrustPrompt != nil)
#expect(appModel.activeGatewayConnectConfig == nil)
#expect(appModel.gatewayStatusText == "Could not save gateway certificate")
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == nil)
}
@Test @MainActor func `certificate rotation preserves route scoped device auth`() async throws {
let stableID = "manual|rotation-\(UUID().uuidString)|443"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
#expect(GatewayTLSStore.replaceFingerprint("old-certificate", stableID: stableID))
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
var options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: nil,
deviceAuthGatewayID: stableID)
options.allowStoredDeviceAuth = true
let config = try GatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: stableID,
tls: GatewayTLSParams(
required: true,
expectedFingerprint: "old-certificate",
allowTOFU: false,
storeKey: stableID),
token: nil,
bootstrapToken: nil,
password: nil,
nodeOptions: options)
appModel.applyGatewayConnectConfig(config)
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let error = GatewayTLSValidationError(
failure: GatewayTLSValidationFailure(
kind: .pinMismatch,
host: "127.0.0.1",
storeKey: stableID,
expectedFingerprint: "old-certificate",
observedFingerprint: "new-certificate",
systemTrustOk: true),
context: "connect to gateway")
let problem = try #require(GatewayConnectionProblemMapper.map(error: error))
let didTrust = await controller.trustRotatedGatewayCertificate(from: problem)
#expect(didTrust)
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new-certificate")
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == stableID)
#expect(appModel.activeGatewayConnectConfig?.tls?.expectedFingerprint == "new-certificate")
}
@Test @MainActor func `cancel during forced reset restores current gateway`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let host = "replacement.gateway.invalid"
let stableID = "manual|\(host)|443"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.saveFingerprint("replacement-fingerprint", stableID: stableID)
let resetFinished = AsyncStream<Void>.makeStream()
let resetRelease = AsyncStream<Void>.makeStream()
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let currentConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: "manual|current.gateway.invalid|443")
appModel.applyGatewayConnectConfig(currentConfig)
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
forceReconnectReset: { appModel in
await appModel.resetGatewaySessionsForForcedReconnect()
resetFinished.continuation.yield()
for await _ in resetRelease.stream {
return
}
})
var finishedIterator = resetFinished.stream.makeAsyncIterator()
await controller.connectManual(host: host, port: 443, useTLS: true, forceReconnect: true)
_ = await finishedIterator.next()
#expect(!appModel._test_hasGatewayLoopTasks().node)
controller.cancelPendingConnectionAttempts()
resetRelease.continuation.yield()
resetRelease.continuation.finish()
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while !appModel._test_hasGatewayLoopTasks().node, ContinuousClock().now < deadline {
await Task.yield()
}
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true)
#expect(appModel._test_hasGatewayLoopTasks().node)
}
@Test @MainActor func `cancel without pending task preserves reconnect pause`() async {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let currentConfig = Self.makeGatewayConnectConfig()
appModel.applyGatewayConnectConfig(currentConfig)
await appModel.resetGatewaySessionsForForcedReconnect()
let problem = GatewayConnectionProblem(
kind: .protocolMismatch,
owner: .gateway,
title: "Protocol mismatch",
message: "Upgrade the gateway before reconnecting.",
retryable: false,
pauseReconnect: true)
appModel._test_applyOperatorGatewayConnectionProblem(problem)
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller.cancelPendingConnectionAttempts()
for _ in 0..<10 {
await Task.yield()
}
#expect(!appModel.gatewayPairingPaused)
#expect(appModel.lastGatewayProblem == problem)
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true)
#expect(!appModel._test_hasGatewayLoopTasks().node)
}
@Test @MainActor func `new connect waits for superseded forced reset`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let forceHost = "192.168.1.39"
let resetRelease = AsyncStream<Void>.makeStream()
var resetStarted = false
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let currentConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: "manual|current.gateway.invalid|443")
appModel.applyGatewayConnectConfig(currentConfig)
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
forceReconnectReset: { appModel in
await appModel.resetGatewaySessionsForForcedReconnect()
resetStarted = true
for await _ in resetRelease.stream {
return
}
})
await controller.connectManual(host: forceHost, port: 18789, useTLS: false, forceReconnect: true)
// Simulator WebSocket teardown can take several seconds under the aggregate iOS suite.
// Keep this bounded while allowing the real session barrier to finish before superseding it.
let resetStartDeadline = ContinuousClock().now.advanced(by: .seconds(10))
while !resetStarted, ContinuousClock().now < resetStartDeadline {
await Task.yield()
}
#expect(resetStarted)
await controller.connectManual(host: "192.168.1.40", port: 18789, useTLS: false)
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true)
#expect(!appModel._test_hasGatewayLoopTasks().node)
resetRelease.continuation.yield()
resetRelease.continuation.finish()
let replacementStableID = "manual|192.168.1.40|18789"
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while appModel.activeGatewayConnectConfig?.stableID != replacementStableID,
ContinuousClock().now < deadline
{
await Task.yield()
}
#expect(appModel.activeGatewayConnectConfig?.stableID == replacementStableID)
#expect(appModel._test_hasGatewayLoopTasks().node)
}
@Test @MainActor func `new connect waits for model owned reset barrier`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let resetRelease = AsyncStream<Void>.makeStream()
let appModel = NodeAppModel()
defer {
appModel._test_setGatewaySessionResetTask(nil)
appModel.disconnectGateway()
}
let currentConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: "manual|current.gateway.invalid|443")
appModel.applyGatewayConnectConfig(currentConfig)
let modelResetTask = Task {
for await _ in resetRelease.stream {
return
}
}
appModel._test_setGatewaySessionResetTask(modelResetTask)
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
await controller.connectManual(host: "192.168.1.41", port: 18789, useTLS: false)
await Task.yield()
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: currentConfig) == true)
resetRelease.continuation.yield()
resetRelease.continuation.finish()
let replacementStableID = "manual|192.168.1.41|18789"
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while appModel.activeGatewayConnectConfig?.stableID != replacementStableID,
ContinuousClock().now < deadline
{
await Task.yield()
}
#expect(appModel.activeGatewayConnectConfig?.stableID == replacementStableID)
}
@Test @MainActor func `trust decline releases suppression without reconnecting unpinned target`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let defaults = UserDefaults.standard
let updates: [String: Any?] = [
"gateway.autoconnect": false,
"gateway.manual.enabled": true,
"gateway.manual.host": "persisted.gateway.invalid",
"gateway.manual.port": 443,
"gateway.manual.tls": true,
"node.instanceId": "ios-test",
]
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
defaults.set(value, forKey: key)
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
let explicitHost = "explicit.gateway.invalid"
let explicitStableID = "manual|\(explicitHost)|443"
defer { GatewayTLSStore.clearFingerprint(stableID: explicitStableID) }
GatewayTLSStore.clearFingerprint(stableID: explicitStableID)
let probeStarted = AsyncStream<Void>.makeStream()
let probeResults = AsyncStream<GatewayTLSFingerprintProbeResult>.makeStream()
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in
probeStarted.continuation.yield()
for await result in probeResults.stream {
return result
}
return .failure(.certificateUnavailable)
})
defaults.set(true, forKey: "gateway.autoconnect")
var startedIterator = probeStarted.stream.makeAsyncIterator()
let connectTask = Task {
await controller.connectManual(host: explicitHost, port: 443, useTLS: true)
}
_ = await startedIterator.next()
controller._test_triggerAutoConnect()
#expect(!controller._test_didAutoConnect())
#expect(appModel.activeGatewayConnectConfig == nil)
probeResults.continuation.yield(.fingerprint("explicit-fingerprint"))
probeResults.continuation.finish()
await connectTask.value
#expect(controller.pendingTrustPrompt?.fingerprintSha256 == "explicit-fingerprint")
controller.declinePendingTrustPrompt()
#expect(!controller._test_didAutoConnect())
#expect(!controller._test_isAutoConnectSuppressed())
#expect(appModel.activeGatewayConnectConfig == nil)
}
@Test @MainActor func `active manual TLS auto connect wins over legacy manual defaults`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let host = "manual-autoconnect-\(UUID().uuidString).example.com"
let stableID = "manual|\(host.lowercased())|443"
let previousStableID = "manual|previous-gateway.example.com|443"
let priorPreviousFingerprint = GatewayTLSStore.loadFingerprint(stableID: previousStableID)
defer {
GatewayTLSStore.clearFingerprint(stableID: stableID)
if let priorPreviousFingerprint {
GatewayTLSStore.saveFingerprint(priorPreviousFingerprint, stableID: previousStableID)
} else {
GatewayTLSStore.clearFingerprint(stableID: previousStableID)
}
}
GatewayTLSStore.saveFingerprint("previous-certificate", stableID: previousStableID)
saveActiveManualGateway(
host: host,
port: 443,
useTLS: true,
stableID: stableID)
await withUserDefaults([
"gateway.autoconnect": true,
"gateway.manual.enabled": true,
"gateway.manual.host": "previous-gateway.example.com",
"gateway.manual.port": 443,
"gateway.manual.tls": true,
"node.instanceId": "ios-test",
"gateway.last.host": nil,
"gateway.last.port": nil,
"gateway.last.tls": nil,
"gateway.last.stableID": nil,
"gateway.last.kind": nil,
"gateway.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil,
]) {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller._test_triggerAutoConnect()
#expect(!controller._test_didAutoConnect())
GatewayTLSStore.saveFingerprint("trusted-certificate", stableID: stableID)
controller._test_triggerAutoConnect()
#expect(controller._test_didAutoConnect())
await waitForActiveGateway(stableID: stableID, appModel: appModel)
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stableID)
}
}
@Test @MainActor func `active local manual gateway auto connects without TLS pin`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let stableID = "manual|127.0.0.1|1"
defer {
GatewayTLSStore.clearFingerprint(stableID: stableID)
}
GatewayTLSStore.clearFingerprint(stableID: stableID)
saveActiveManualGateway(host: "127.0.0.1", port: 1, useTLS: false, stableID: stableID)
await withUserDefaults([
"gateway.autoconnect": true,
"gateway.manual.enabled": false,
"node.instanceId": "ios-test",
]) {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller._test_triggerAutoConnect()
#expect(controller._test_didAutoConnect())
await waitForActiveGateway(stableID: stableID, appModel: appModel)
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stableID)
#expect(appModel.activeGatewayConnectConfig?.url == URL(string: "ws://127.0.0.1:1"))
}
}
@Test @MainActor func `missing active discovered gateway auto connects to latest manual gateway`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let manualID = "manual|127.0.0.1|1"
saveActiveManualGateway(host: "127.0.0.1", port: 1, useTLS: false, stableID: manualID)
_ = GatewaySettingsStore.markGatewayConnected(stableID: manualID, atMs: 42)
let discoveredID = "bonjour|missing"
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: discoveredID,
kind: .discovered,
name: "Missing Gateway",
host: nil,
port: nil,
useTLS: true,
lastConnectedAtMs: 84))
_ = GatewaySettingsStore.setActiveGateway(stableID: discoveredID)
await withUserDefaults([
"gateway.autoconnect": true,
"gateway.manual.enabled": false,
"node.instanceId": "ios-test",
]) {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller._test_triggerAutoConnect()
#expect(controller._test_didAutoConnect())
await waitForActiveGateway(stableID: manualID, appModel: appModel)
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == manualID)
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == manualID)
}
}
@Test @MainActor func `forget gateway clears matching share relay only`() {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let priorRelay = ShareGatewayRelaySettings.loadConfig()
defer {
if let priorRelay {
ShareGatewayRelaySettings.saveConfig(priorRelay)
} else {
ShareGatewayRelaySettings.clearConfig()
}
}
let stableID = "manual|forgotten.example.com|443"
ShareGatewayRelaySettings.saveConfig(.init(
gatewayURLString: "wss://forgotten.example.com",
gatewayStableID: stableID,
token: "relay-token",
password: nil,
sessionKey: "main"))
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller.forgetGateway(stableID: stableID)
#expect(ShareGatewayRelaySettings.loadConfig() == nil)
}
@Test @MainActor func `forget manual gateway clears matching legacy auto connect defaults`() {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let host = "forgotten.example.com"
let port = 443
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(host: host, port: port)
withUserDefaults([
"gateway.manual.enabled": true,
"gateway.manual.host": host,
"gateway.manual.port": port,
"gateway.manual.tls": true,
]) {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller.forgetGateway(stableID: stableID)
let defaults = UserDefaults.standard
#expect(!defaults.bool(forKey: "gateway.manual.enabled"))
#expect(defaults.object(forKey: "gateway.manual.host") == nil)
#expect(defaults.object(forKey: "gateway.manual.port") == nil)
#expect(defaults.object(forKey: "gateway.manual.tls") == nil)
}
}
@Test @MainActor func `forget gateway preserves legacy defaults for another manual gateway`() {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
withUserDefaults([
"gateway.manual.enabled": true,
"gateway.manual.host": "kept.example.com",
"gateway.manual.port": 443,
"gateway.manual.tls": true,
]) {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller.forgetGateway(stableID: "manual|forgotten.example.com|443")
let defaults = UserDefaults.standard
#expect(defaults.bool(forKey: "gateway.manual.enabled"))
#expect(defaults.string(forKey: "gateway.manual.host") == "kept.example.com")
#expect(defaults.integer(forKey: "gateway.manual.port") == 443)
#expect(defaults.bool(forKey: "gateway.manual.tls"))
}
}
@Test @MainActor func `forget connected gateway disconnects and repeats device auth cleanup`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let service = GatewaySettingsStore._testGatewayService
let account = "gateway-registry"
let prior = KeychainStore.loadString(service: service, account: account)
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: service, account: account)
} else {
_ = KeychainStore.delete(service: service, account: account)
}
}
_ = KeychainStore.delete(service: service, account: account)
let connectedID = "manual|connected.example.com|443"
let selectedID = "manual|selected.example.com|443"
saveActiveManualGateway(host: "selected.example.com", port: 443, useTLS: true, stableID: selectedID)
let appModel = NodeAppModel()
try appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://connected.example.com")),
stableID: connectedID))
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let identity = DeviceIdentityStore.loadOrCreate()
defer { DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "node", gatewayID: connectedID) }
controller.forgetGateway(stableID: connectedID)
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "node",
token: "late-handshake-token",
gatewayID: connectedID)
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: connectedID) != nil,
ContinuousClock().now < deadline
{
try await Task.sleep(for: .milliseconds(10))
}
#expect(appModel.activeGatewayConnectConfig == nil)
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == selectedID)
#expect(DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: connectedID) == nil)
}
@Test @MainActor func `forget selected gateway preserves a different live route`() throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let service = GatewaySettingsStore._testGatewayService
let account = "gateway-registry"
let prior = KeychainStore.loadString(service: service, account: account)
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: service, account: account)
} else {
_ = KeychainStore.delete(service: service, account: account)
}
}
_ = KeychainStore.delete(service: service, account: account)
let selectedID = "manual|selected.example.com|443"
let connectedID = "manual|connected.example.com|443"
saveActiveManualGateway(host: "selected.example.com", port: 443, useTLS: true, stableID: selectedID)
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: connectedID,
kind: .manual,
name: "connected.example.com:443",
host: "connected.example.com",
port: 443,
useTLS: true,
lastConnectedAtMs: nil))
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let connectedConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://connected.example.com")),
stableID: connectedID)
appModel.applyGatewayConnectConfig(connectedConfig)
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller.forgetGateway(stableID: selectedID)
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: connectedConfig) == true)
#expect(GatewaySettingsStore.activeGatewayEntry() == nil)
#expect(GatewaySettingsStore.loadGatewayRegistry().entries.map(\.stableID) == [connectedID])
}
@Test @MainActor func `forget gateway clears only matching legacy discovery selectors`() {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let service = GatewaySettingsStore._testGatewayService
let preferredAccount = "preferredStableID"
let lastAccount = "lastDiscoveredStableID"
let priorPreferred = KeychainStore.loadString(service: service, account: preferredAccount)
let priorLast = KeychainStore.loadString(service: service, account: lastAccount)
defer {
if let priorPreferred {
_ = KeychainStore.saveString(priorPreferred, service: service, account: preferredAccount)
} else {
_ = KeychainStore.delete(service: service, account: preferredAccount)
}
if let priorLast {
_ = KeychainStore.saveString(priorLast, service: service, account: lastAccount)
} else {
_ = KeychainStore.delete(service: service, account: lastAccount)
}
}
let forgottenID = "bonjour|forgotten"
let keptID = "bonjour|kept"
_ = KeychainStore.saveString(forgottenID, service: service, account: preferredAccount)
_ = KeychainStore.saveString(keptID, service: service, account: lastAccount)
withUserDefaults([
"gateway.preferredStableID": forgottenID,
"gateway.lastDiscoveredStableID": keptID,
]) {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller.forgetGateway(stableID: forgottenID)
let defaults = UserDefaults.standard
#expect(defaults.object(forKey: "gateway.preferredStableID") == nil)
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == keptID)
#expect(KeychainStore.loadString(service: service, account: preferredAccount) == nil)
#expect(KeychainStore.loadString(service: service, account: lastAccount) == keptID)
}
}
@Test @MainActor func `forget gateway cancels its pending trust handoff`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let service = GatewaySettingsStore._testGatewayService
let account = "gateway-registry"
let prior = KeychainStore.loadString(service: service, account: account)
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: service, account: account)
} else {
_ = KeychainStore.delete(service: service, account: account)
}
}
_ = KeychainStore.delete(service: service, account: account)
let host = "pending-trust.example.com"
let port = 443
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(host: host, port: port)
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
GatewayTLSStore.clearFingerprint(stableID: stableID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("forgotten-fingerprint") })
await controller.connectManual(host: host, port: port, useTLS: true)
#expect(controller.pendingTrustPrompt?.stableID == stableID)
controller.forgetGateway(stableID: stableID)
await controller.acceptPendingTrustPrompt()
#expect(controller.pendingTrustPrompt == nil)
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == nil)
#expect(!GatewaySettingsStore.loadGatewayRegistry().entries.contains { $0.stableID == stableID })
}
@Test @MainActor func `forget gateway preserves another gateway pending trust handoff`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let pendingHost = "pending-kept.example.com"
let pendingID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: pendingHost,
port: 443)
defer { GatewayTLSStore.clearFingerprint(stableID: pendingID) }
GatewayTLSStore.clearFingerprint(stableID: pendingID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("kept-fingerprint") })
await controller.connectManual(host: pendingHost, port: 443, useTLS: true)
#expect(controller.pendingTrustPrompt?.stableID == pendingID)
controller.forgetGateway(stableID: "bonjour|unrelated-forgotten")
#expect(controller.pendingTrustPrompt?.stableID == pendingID)
}
@Test @MainActor func `forget live gateway preserves replacement pending trust generation`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let connectedID = "manual|connected-before-forget.example.com|443"
let replacementHost = "replacement-after-forget.example.com"
let replacementID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: replacementHost,
port: 443)
defer { GatewayTLSStore.clearFingerprint(stableID: replacementID) }
GatewayTLSStore.clearFingerprint(stableID: replacementID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
try appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://connected-before-forget.example.com")),
stableID: connectedID))
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("replacement-fingerprint") })
await controller.connectManual(host: replacementHost, port: 443, useTLS: true)
#expect(controller.pendingTrustPrompt?.stableID == replacementID)
controller.forgetGateway(stableID: connectedID)
await controller.acceptPendingTrustPrompt()
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while appModel.activeGatewayConnectConfig?.effectiveStableID != replacementID,
ContinuousClock().now < deadline
{
try await Task.sleep(for: .milliseconds(10))
}
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == replacementID)
}
@Test @MainActor func `stale cancellation lease cannot release newer suppression`() {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let staleLease = controller.cancelPendingConnectionAttempts()
let currentLease = controller.cancelPendingConnectionAttempts()
controller.resumeAutoConnect(after: staleLease)
#expect(controller._test_isAutoConnectSuppressed())
controller.resumeAutoConnect(after: currentLease)
#expect(!controller._test_isAutoConnectSuppressed())
}
@Test @MainActor func `cancellation lease restores previous auto connect state`() {
withUserDefaults([
"gateway.autoconnect": true,
"gateway.manual.enabled": false,
"gateway.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil,
]) {
let appModel = NodeAppModel()
appModel.gatewayAutoReconnectEnabled = true
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let scannerLease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
#expect(!appModel.gatewayAutoReconnectEnabled)
#expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect"))
let replacementLease = controller.cancelPendingConnectionAttempts()
controller.resumeAutoConnect(after: scannerLease)
#expect(!appModel.gatewayAutoReconnectEnabled)
controller.resumeAutoConnect(after: replacementLease)
#expect(appModel.gatewayAutoReconnectEnabled)
#expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect"))
}
}
@Test @MainActor func `completed target switch does not restore auto connect state`() {
withUserDefaults(["gateway.autoconnect": true]) {
let appModel = NodeAppModel()
appModel.gatewayAutoReconnectEnabled = true
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let lease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
controller.releaseAutoConnectSuppression(after: lease)
#expect(!controller._test_isAutoConnectSuppressed())
#expect(!appModel.gatewayAutoReconnectEnabled)
#expect(UserDefaults.standard.bool(forKey: "gateway.autoconnect"))
}
}
@Test @MainActor func `auto connect choice made during target review wins`() throws {
try withUserDefaults(["gateway.autoconnect": true]) {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let suspendedConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://127.0.0.1:1")),
stableID: "manual|127.0.0.1|1")
appModel.applyGatewayConnectConfig(suspendedConfig)
appModel.gatewayAutoReconnectEnabled = true
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let lease = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
UserDefaults.standard.set(false, forKey: "gateway.autoconnect")
controller.resumeAutoConnect(after: lease)
#expect(!appModel.gatewayAutoReconnectEnabled)
#expect(appModel.activeGatewayConnectConfig == nil)
#expect(!UserDefaults.standard.bool(forKey: "gateway.autoconnect"))
}
}
@Test @MainActor func `failed replacement restores inherited scanner reconnect state`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let defaults = UserDefaults.standard
let updates: [String: Any?] = [
"gateway.autoconnect": true,
"gateway.manual.enabled": false,
"node.instanceId": "ios-test",
"gateway.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil,
]
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let suspendedConfig = try Self.makeGatewayConnectConfig(
url: #require(URL(string: "ws://127.0.0.1:1")),
stableID: "manual|127.0.0.1|1")
appModel.applyGatewayConnectConfig(suspendedConfig)
appModel.gatewayAutoReconnectEnabled = true
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
_ = controller.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
await controller.connectManual(host: "invalid.example.com", port: 70000, useTLS: true)
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: suspendedConfig) != true,
ContinuousClock().now < deadline
{
await Task.yield()
}
#expect(!controller._test_isAutoConnectSuppressed())
#expect(appModel.gatewayAutoReconnectEnabled)
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: suspendedConfig) == true)
#expect(defaults.bool(forKey: "gateway.autoconnect"))
}
@Test @MainActor func `foreground reconnect cannot replace queued explicit handoff`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let defaults = UserDefaults.standard
let updates: [String: Any?] = [
"gateway.autoconnect": false,
"gateway.manual.enabled": true,
"gateway.manual.host": "192.168.1.20",
"gateway.manual.port": 18789,
"gateway.manual.tls": false,
"node.instanceId": "ios-test",
]
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
defaults.set(value, forKey: key)
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
let resetRelease = AsyncStream<Void>.makeStream()
let appModel = NodeAppModel()
defer {
resetRelease.continuation.finish()
appModel._test_setGatewaySessionResetTask(nil)
appModel.disconnectGateway()
}
let modelResetTask = Task {
for await _ in resetRelease.stream {
return
}
}
appModel._test_setGatewaySessionResetTask(modelResetTask)
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
defaults.set(true, forKey: "gateway.autoconnect")
let explicitStableID = "manual|192.168.1.41|18789"
await controller.connectManual(host: "192.168.1.41", port: 18789, useTLS: false)
#expect(appModel.activeGatewayConnectConfig == nil)
controller._test_triggerAutoReconnect()
for _ in 0..<10 {
await Task.yield()
}
#expect(controller._test_didAutoConnect())
#expect(appModel.activeGatewayConnectConfig == nil)
resetRelease.continuation.yield()
resetRelease.continuation.finish()
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while appModel.activeGatewayConnectConfig?.stableID != explicitStableID,
ContinuousClock().now < deadline
{
await Task.yield()
}
#expect(appModel.activeGatewayConnectConfig?.stableID == explicitStableID)
controller._test_triggerAutoConnect()
#expect(controller._test_didAutoConnect())
#expect(appModel.activeGatewayConnectConfig?.stableID == explicitStableID)
}
@Test @MainActor func `clearing trust prompt invalidates in flight probe`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let probeStarted = AsyncStream<Void>.makeStream()
let probeResults = AsyncStream<GatewayTLSFingerprintProbeResult>.makeStream()
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in
probeStarted.continuation.yield()
for await result in probeResults.stream {
return result
}
return .failure(.certificateUnavailable)
})
var startedIterator = probeStarted.stream.makeAsyncIterator()
let connectTask = Task {
await controller.connectManual(host: "trust-probe-cancel.invalid", port: 443, useTLS: true)
}
_ = await startedIterator.next()
controller.clearPendingTrustPrompt()
probeResults.continuation.yield(.fingerprint("stale-fingerprint"))
probeResults.continuation.finish()
await connectTask.value
#expect(controller.pendingTrustPrompt == nil)
}
@Test @MainActor func `foreground stale connection restart reapplies active gateway config`() async {
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let config = Self.makeGatewayConnectConfig()
appModel.applyGatewayConnectConfig(config)
await appModel._test_restartGatewaySessionsAfterForegroundStaleConnection()
#expect(appModel.gatewayStatusText == "Reconnecting…")
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: config) == true)
#expect(appModel._test_hasGatewayLoopTasks().node)
#expect(appModel._test_hasGatewayLoopTasks().operator)
}
@Test @MainActor func `switch to manual gateway applies its stable I D and URL`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let service = GatewaySettingsStore._testGatewayService
let account = "gateway-registry"
let prior = KeychainStore.loadString(service: service, account: account)
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: service, account: account)
} else {
_ = KeychainStore.delete(service: service, account: account)
}
}
_ = KeychainStore.delete(service: service, account: account)
let stableID = "manual|127.0.0.1|1"
saveActiveManualGateway(host: "127.0.0.1", port: 1, useTLS: false, stableID: stableID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
forceReconnectReset: { _ in })
let failure = await controller.switchToGateway(stableID: stableID)
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
while appModel.activeGatewayConnectConfig == nil, ContinuousClock().now < deadline {
await Task.yield()
}
#expect(failure == nil)
#expect(appModel.activeGatewayConnectConfig?.effectiveStableID == stableID)
#expect(appModel.activeGatewayConnectConfig?.url == URL(string: "ws://127.0.0.1:1"))
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == stableID)
}
@Test @MainActor func `switch to undiscoverable gateway returns failure without changing active gateway`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let service = GatewaySettingsStore._testGatewayService
let account = "gateway-registry"
let prior = KeychainStore.loadString(service: service, account: account)
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: service, account: account)
} else {
_ = KeychainStore.delete(service: service, account: account)
}
}
_ = KeychainStore.delete(service: service, account: account)
let activeID = "manual|127.0.0.1|1"
let discoveredID = "bonjour|missing"
saveActiveManualGateway(host: "127.0.0.1", port: 1, useTLS: false, stableID: activeID)
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: discoveredID,
kind: .discovered,
name: "Kitchen Gateway",
host: nil,
port: nil,
useTLS: true,
lastConnectedAtMs: nil))
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let failure = await controller.switchToGateway(stableID: discoveredID)
#expect(failure == "Kitchen Gateway is not currently discoverable on this network.")
#expect(GatewaySettingsStore.activeGatewayEntry()?.stableID == activeID)
#expect(appModel.activeGatewayConnectConfig == nil)
}
@Test @MainActor func `chat cache remains isolated when active gateway switches`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let service = GatewaySettingsStore._testGatewayService
let account = "gateway-registry"
let prior = KeychainStore.loadString(service: service, account: account)
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: service, account: account)
} else {
_ = KeychainStore.delete(service: service, account: account)
}
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
_ = KeychainStore.delete(service: service, account: account)
let gatewayA = "manual|gateway-a|18789"
let gatewayB = "manual|gateway-b|18789"
saveActiveManualGateway(host: "gateway-a", port: 18789, useTLS: false, stableID: gatewayA)
_ = GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: gatewayB,
kind: .manual,
name: "Gateway B",
host: "gateway-b",
port: 18789,
useTLS: false,
lastConnectedAtMs: nil))
let appModel = NodeAppModel()
let session = OpenClawChatSessionEntry(
key: "agent:main:a",
kind: nil,
displayName: "Gateway A session",
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: 1,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil)
await appModel.storeCachedChatSessions([session])
_ = GatewaySettingsStore.setActiveGateway(stableID: gatewayB)
#expect(await appModel.loadCachedChatSessions().isEmpty)
_ = GatewaySettingsStore.setActiveGateway(stableID: gatewayA)
#expect(await appModel.loadCachedChatSessions() == [session])
}
private static func makeGatewayConnectConfig(
// Fail locally instead of making lifecycle tests depend on DNS or external network timing.
url: URL = URL(string: "wss://127.0.0.1:1")!,
stableID: String = "manual|gateway.example.com|443") -> GatewayConnectConfig
{
GatewayConnectConfig(
url: url,
stableID: stableID,
tls: GatewayTLSParams(
required: true,
expectedFingerprint: "abc",
allowTOFU: false,
storeKey: stableID),
token: "token",
bootstrapToken: nil,
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: ["screen", "canvas"],
commands: ["notify", "location.get"],
permissions: ["screen": true],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone"))
}
}