From 9f501c77a65337f75ad2eafdf4aaa139a1005abf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 05:08:45 -0700 Subject: [PATCH] fix(macos): migrate legacy Gateway profiles (#112629) --- .../Sources/OpenClaw/MacGatewayProfiles.swift | 79 ++++++++++++++++--- .../MacGatewayProfilesTests.swift | 79 +++++++++++++++++++ 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift index 626495cbb7f7..a0abc0783c78 100644 --- a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift +++ b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift @@ -37,13 +37,14 @@ enum MacGatewayProfileError: LocalizedError, Equatable { actor MacGatewayProfileStore { static let shared = MacGatewayProfileStore() - private struct StoredProfile: Codable { + struct StoredProfile: Codable, Equatable { var profile: MacGatewayProfile var credentials: Credentials } - private struct Registry: Codable { + struct Registry: Codable, Equatable { var version = 1 + var legacyPrimaryMigrationVersion: Int? var profiles: [StoredProfile] = [] } @@ -54,6 +55,37 @@ actor MacGatewayProfileStore { private static let service = "ai.openclaw.gateway-profiles" private static let registryAccount = "registry-v1" + private static let currentLegacyPrimaryMigrationVersion = 1 + + static func migratingLegacyPrimaryConnection( + root: [String: Any], + registry: Registry) -> Registry + { + guard (registry.legacyPrimaryMigrationVersion ?? 0) < self.currentLegacyPrimaryMigrationVersion else { + return registry + } + var migrated = registry + migrated.legacyPrimaryMigrationVersion = self.currentLegacyPrimaryMigrationVersion + + let mode = ConnectionModeResolver.resolve(root: root) + let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root) + guard mode.mode == .remote, + mode.source == .configMode || mode.source == .configRemoteURL, + resolution.transport == .direct, + let directURL = resolution.directURL, + let profile = try? self.makeProfile(name: "", url: directURL), + !migrated.profiles.contains(where: { $0.profile.id == profile.id }) + else { + return migrated + } + + migrated.profiles.append(StoredProfile( + profile: profile, + credentials: Credentials( + token: GatewayRemoteConfig.resolveTokenString(root: root), + password: GatewayRemoteConfig.resolvePasswordString(root: root)))) + return migrated + } func upsert( name: String, @@ -61,14 +93,9 @@ actor MacGatewayProfileStore { token: String?, password: String?) throws -> MacGatewayProfile { - let canonicalURL = try Self.canonicalURL(url) - let id = Self.profileID(url: canonicalURL) - let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - let profile = MacGatewayProfile( - id: id, - name: trimmedName.isEmpty ? (canonicalURL.host ?? canonicalURL.absoluteString) : trimmedName, - url: canonicalURL) + let profile = try Self.makeProfile(name: name, url: url) var registry = try self.loadRegistry() + let id = profile.id let savedCredentials = registry.profiles.first { $0.profile.id == id }?.credentials let credentials = Self.resolvedCredentials( saved: savedCredentials, @@ -78,12 +105,12 @@ actor MacGatewayProfileStore { registry.profiles.append(StoredProfile(profile: profile, credentials: credentials)) // Metadata and secrets share one Keychain value, so the profile becomes // reachable only when the complete record commits. - try Self.save(JSONEncoder().encode(registry), account: Self.registryAccount) + try self.saveRegistry(registry) return profile } func profiles() throws -> [MacGatewayProfile] { - try Self.sortedProfiles(self.loadRegistry().profiles.map(\.profile)) + try Self.sortedProfiles(self.loadRegistryMigratingLegacyPrimary().profiles.map(\.profile)) } func remove(profileID: String) throws { @@ -92,7 +119,7 @@ actor MacGatewayProfileStore { throw MacGatewayProfileError.profileNotFound } registry.profiles.removeAll { $0.profile.id == profileID } - try Self.save(JSONEncoder().encode(registry), account: Self.registryAccount) + try self.saveRegistry(registry) } func endpoint(profileID: String) throws -> GatewayConnection.EndpointSnapshot { @@ -116,6 +143,25 @@ actor MacGatewayProfileStore { return try Self.decodeRegistry(data) } + private func loadRegistryMigratingLegacyPrimary() throws -> Registry { + let registry = try self.loadRegistry() + // Keep the receipt in the registry so removing the imported profile is durable. + // A failed Keychain commit leaves both changes unapplied and retries on the next read. + guard (registry.legacyPrimaryMigrationVersion ?? 0) < Self.currentLegacyPrimaryMigrationVersion else { + return registry + } + let migrated = Self.migratingLegacyPrimaryConnection( + root: OpenClawConfigFile.loadDict(), + registry: registry) + guard migrated != registry else { return registry } + try self.saveRegistry(migrated) + return migrated + } + + private func saveRegistry(_ registry: Registry) throws { + try Self.save(JSONEncoder().encode(registry), account: Self.registryAccount) + } + private static func decodeRegistry(_ data: Data) throws -> Registry { let registry = try JSONDecoder().decode(Registry.self, from: data) guard registry.version == 1 else { @@ -138,6 +184,15 @@ actor MacGatewayProfileStore { } } + private static func makeProfile(name: String, url: URL) throws -> MacGatewayProfile { + let canonicalURL = try self.canonicalURL(url) + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + return MacGatewayProfile( + id: self.profileID(url: canonicalURL), + name: trimmedName.isEmpty ? (canonicalURL.host ?? canonicalURL.absoluteString) : trimmedName, + url: canonicalURL) + } + static func canonicalURL(_ url: URL) throws -> URL { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), let scheme = components.scheme?.lowercased(), diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift index 10d3f9ffe845..50e72528ff6d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift @@ -142,4 +142,83 @@ struct MacGatewayProfilesTests { #expect(WebChatManager.preferredProfileIndex(profiles: profiles, preferredID: "two") == 1) #expect(WebChatManager.preferredProfileIndex(profiles: profiles, preferredID: "missing") == 0) } + + @Test func `legacy direct primary Gateway migrates once with credentials`() throws { + let root = self.remoteRoot( + url: "WSS://Studio.Example/alpha", + token: " legacy-token ", + password: " legacy-password ") + let original = MacGatewayProfileStore.Registry() + + let migrated = MacGatewayProfileStore.migratingLegacyPrimaryConnection( + root: root, + registry: original) + + #expect(original.legacyPrimaryMigrationVersion == nil) + #expect(migrated.legacyPrimaryMigrationVersion == 1) + let stored = try #require(migrated.profiles.first) + #expect(stored.profile.name == "studio.example") + #expect(stored.profile.url.absoluteString == "wss://studio.example:443/alpha") + #expect(stored.credentials.token == "legacy-token") + #expect(stored.credentials.password == "legacy-password") + #expect(MacGatewayProfileStore.migratingLegacyPrimaryConnection( + root: root, + registry: migrated) == migrated) + #expect(MacGatewayProfileStore.migratingLegacyPrimaryConnection( + root: root, + registry: original) == migrated) + } + + @Test func `legacy migration preserves an existing profile for the same route`() throws { + let url = try MacGatewayProfileStore.canonicalURL( + #require(URL(string: "wss://studio.example"))) + let existing = MacGatewayProfileStore.StoredProfile( + profile: MacGatewayProfile( + id: MacGatewayProfileStore.profileID(url: url), + name: "My Studio", + url: url), + credentials: .init(token: "saved-token", password: "saved-password")) + let registry = MacGatewayProfileStore.Registry(profiles: [existing]) + + let migrated = MacGatewayProfileStore.migratingLegacyPrimaryConnection( + root: self.remoteRoot( + url: "wss://studio.example", + token: "new-token", + password: "new-password"), + registry: registry) + + #expect(migrated.legacyPrimaryMigrationVersion == 1) + #expect(migrated.profiles == [existing]) + } + + @Test func `legacy migration skips routes that are not active direct Gateways`() { + let cases: [[String: Any]] = [ + self.remoteRoot(url: "ws://127.0.0.1:18789", transport: "ssh"), + self.remoteRoot(url: "wss://studio.example", mode: "local"), + self.remoteRoot(url: "https://studio.example"), + [:], + ] + + for root in cases { + let migrated = MacGatewayProfileStore.migratingLegacyPrimaryConnection( + root: root, + registry: .init()) + #expect(migrated.legacyPrimaryMigrationVersion == 1) + #expect(migrated.profiles.isEmpty) + } + } + + private func remoteRoot( + url: String, + mode: String = "remote", + transport: String? = nil, + token: String? = nil, + password: String? = nil) -> [String: Any] + { + var remote: [String: Any] = ["url": url] + if let transport { remote["transport"] = transport } + if let token { remote["token"] = token } + if let password { remote["password"] = password } + return ["gateway": ["mode": mode, "remote": remote]] + } }