mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(macos): migrate legacy Gateway profiles (#112629)
This commit is contained in:
committed by
GitHub
parent
c4fc4a70ee
commit
9f501c77a6
@@ -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(),
|
||||
|
||||
@@ -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]]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user