import Foundation import OpenClawKit import os enum GatewaySettingsStore { private static let productionGatewayService = "ai.openclawfoundation.app.gateway" private static var gatewayService: String { #if DEBUG // Hosted tests share the app's Keychain access group; keep fixtures away from installed-app state. if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { return "\(self.productionGatewayService).tests" } #endif return self.productionGatewayService } #if DEBUG static var _testGatewayService: String { self.gatewayService } #endif private static let nodeService = "ai.openclawfoundation.app.node" private static let talkService = "ai.openclawfoundation.app.talk" private static let instanceIdDefaultsKey = "node.instanceId" private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID" private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID" private static let lastGatewayKindDefaultsKey = "gateway.last.kind" private static let lastGatewayHostDefaultsKey = "gateway.last.host" private static let lastGatewayPortDefaultsKey = "gateway.last.port" private static let lastGatewayTlsDefaultsKey = "gateway.last.tls" private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID" private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride." private static let selectedAgentDefaultsPrefix = "gateway.selectedAgentId." private static let instanceIdAccount = "instanceId" private static let preferredGatewayStableIDAccount = "preferredStableID" private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" private static let gatewayRegistryAccount = "gateway-registry" private static let lastGatewayConnectionAccount = "lastConnection" private static let gatewayCustomHeadersService = "ai.openclawfoundation.app.gateway.custom-headers" private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret struct GatewayRegistryEntry: Codable, Equatable, Identifiable { enum Kind: String, Codable { case manual case discovered } var stableID: String var kind: Kind var name: String var host: String? var port: Int? var useTLS: Bool var lastConnectedAtMs: Int? var id: GatewayStableIdentifier.Key { GatewayStableIdentifier.Key(self.stableID) } static func == (lhs: Self, rhs: Self) -> Bool { GatewayStableIdentifier.matches(lhs.stableID, rhs.stableID) && lhs.kind == rhs.kind && lhs.name == rhs.name && lhs.host == rhs.host && lhs.port == rhs.port && lhs.useTLS == rhs.useTLS && lhs.lastConnectedAtMs == rhs.lastConnectedAtMs } } struct GatewayRegistry: Codable, Equatable { var version: Int = 1 var activeStableID: String? var entries: [GatewayRegistryEntry] = [] static let empty = GatewayRegistry() } struct GatewayCredentialMetadata: Codable, Equatable { let gatewayStableID: String let suppressStoredDeviceAuth: Bool } /// Credential ownership and secrets must move together. Separate Keychain /// entries can survive a partial update and bind one gateway's secret to another. private struct GatewayCredentialBundle: Codable { let gatewayStableID: String let suppressStoredDeviceAuth: Bool let token: String? let bootstrapToken: String? let password: String? } struct GatewayCredentials: Equatable { let token: String? let bootstrapToken: String? let password: String? let suppressStoredDeviceAuth: Bool static let empty = GatewayCredentials( token: nil, bootstrapToken: nil, password: nil, suppressStoredDeviceAuth: false) var hasCredentials: Bool { self.token != nil || self.bootstrapToken != nil || self.password != nil } } static func bootstrapPersistence() { self.ensureStableInstanceID() self.ensurePreferredGatewayStableID() self.ensureLastDiscoveredGatewayStableID() self.migrateGatewayRegistryIfNeeded() if let instanceID = self.loadStableInstanceID() { self.migrateGatewayCredentialBundleIfNeeded(instanceId: instanceID) } } static func currentInstanceID(defaults: UserDefaults = .standard) -> String { self.bootstrapPersistence() if let value = defaults.string(forKey: self.instanceIdDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { return value } return self.loadStableInstanceID() ?? "" } static func loadStableInstanceID() -> String? { if let value = KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)? .trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { return value } return nil } static func saveStableInstanceID(_ instanceId: String) { _ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount) } static func loadPreferredGatewayStableID() -> String? { GatewayStableIdentifier.exact(KeychainStore.loadString( service: self.gatewayService, account: self.preferredGatewayStableIDAccount)) } static func savePreferredGatewayStableID(_ stableID: String) { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return } _ = KeychainStore.saveString( stableID, service: self.gatewayService, account: self.preferredGatewayStableIDAccount) } static func clearPreferredGatewayStableID(defaults: UserDefaults = .standard) { _ = KeychainStore.delete( service: self.gatewayService, account: self.preferredGatewayStableIDAccount) defaults.removeObject(forKey: self.preferredGatewayStableIDDefaultsKey) } static func loadLastDiscoveredGatewayStableID() -> String? { GatewayStableIdentifier.exact(KeychainStore.loadString( service: self.gatewayService, account: self.lastDiscoveredGatewayStableIDAccount)) } static func saveLastDiscoveredGatewayStableID(_ stableID: String) { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return } _ = KeychainStore.saveString( stableID, service: self.gatewayService, account: self.lastDiscoveredGatewayStableIDAccount) } static func clearLastDiscoveredGatewayStableID(defaults: UserDefaults = .standard) { _ = KeychainStore.delete( service: self.gatewayService, account: self.lastDiscoveredGatewayStableIDAccount) defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) } static func loadGatewayCredentialMetadata( instanceId: String, gatewayStableID: String) -> GatewayCredentialMetadata? { guard let bundle = self.loadGatewayCredentialBundle( instanceId: instanceId, gatewayStableID: gatewayStableID) else { return nil } return GatewayCredentialMetadata( gatewayStableID: bundle.gatewayStableID, suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth) } static func loadGatewayCredentials(instanceId: String, gatewayStableID: String) -> GatewayCredentials { let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) guard !stableID.isEmpty, let bundle = self.loadGatewayCredentialBundle( instanceId: instanceId, gatewayStableID: stableID) else { return .empty } return GatewayCredentials( token: bundle.token, bootstrapToken: bundle.bootstrapToken, password: bundle.password, suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth) } @discardableResult static func saveGatewayCredentials( token: String?, bootstrapToken: String?, password: String?, gatewayStableID: String, suppressStoredDeviceAuth: Bool, instanceId: String) -> Bool { let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !stableID.isEmpty, !trimmedInstanceID.isEmpty else { return false } let bundle = GatewayCredentialBundle( gatewayStableID: stableID, suppressStoredDeviceAuth: suppressStoredDeviceAuth, token: self.normalizedCredential(token), bootstrapToken: self.normalizedCredential(bootstrapToken), password: self.normalizedCredential(password)) let account = self.gatewayCredentialBundleAccount( instanceId: trimmedInstanceID, stableID: stableID) let hasCredentials = bundle.token != nil || bundle.bootstrapToken != nil || bundle.password != nil guard hasCredentials || suppressStoredDeviceAuth else { let deleted = KeychainStore.delete(service: self.gatewayService, account: account) self.deleteLegacyScopedCredentialBundleIfOwned( instanceId: trimmedInstanceID, stableID: stableID) self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID) return deleted || KeychainStore.loadString(service: self.gatewayService, account: account) == nil } guard let data = try? JSONEncoder().encode(bundle), let json = String(data: data, encoding: .utf8) else { _ = KeychainStore.delete(service: self.gatewayService, account: account) return false } guard KeychainStore.saveString( json, service: self.gatewayService, account: account) else { // The Keychain helper restores the prior item when replacement fails. Keep that // known-good bundle; callers already treat this attempted update as uncommitted. return false } self.deleteLegacyScopedCredentialBundleIfOwned( instanceId: trimmedInstanceID, stableID: stableID) self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID) return true } @discardableResult static func updateGatewayCredentials( token: String?, password: String?, gatewayStableID: String, instanceId: String) -> Bool { let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) let existing = self.loadGatewayCredentialBundle( instanceId: instanceId, gatewayStableID: stableID) return self.saveGatewayCredentials( token: token, bootstrapToken: existing?.bootstrapToken, password: password, gatewayStableID: stableID, suppressStoredDeviceAuth: existing?.suppressStoredDeviceAuth == true, instanceId: instanceId) } @discardableResult static func completeGatewayCredentialHandoff(instanceId: String, gatewayStableID: String) -> Bool { let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) guard let bundle = self.loadGatewayCredentialBundle( instanceId: instanceId, gatewayStableID: stableID), bundle.suppressStoredDeviceAuth else { return false } // Device-token issuance and bootstrap consumption are one durable handoff. A relaunch // must never observe a spent bootstrap token while stored device auth remains disabled. return self.saveGatewayCredentials( token: bundle.token, bootstrapToken: nil, password: bundle.password, gatewayStableID: stableID, suppressStoredDeviceAuth: false, instanceId: instanceId) } static func discardUnscopedGatewayCredentials(instanceId: String) { // The legacy UI saved fields before a successful connection, so the last route // cannot prove who owns these secrets. Re-entry is safer than cross-gateway reuse. self.deleteLegacyGatewayCredentials(instanceId: instanceId) } /// Certificate pins prove transport trust for one route; they are not gateway identities. /// Wildcard certificates and reverse proxies may legitimately reuse a leaf certificate. static func authenticationOwnerID(routeStableID: String) -> String { GatewayStableIdentifier.exact(routeStableID) ?? "" } /// Custom proxy headers are per-gateway credentials (Cloudflare Access-style service /// tokens). They live in the Keychain like the other gateway secrets and are read at /// connect time; never log their values. static func loadGatewayCustomHeaders(gatewayStableID: String) -> [String: String] { self.loadGatewayCustomHeaders(gatewayStableID: gatewayStableID, service: self.gatewayCustomHeadersService) } static func loadGatewayCustomHeaders( gatewayStableID: String, service: String) -> [String: String] { let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) guard !stableID.isEmpty else { return [:] } let account = self.customHeadersAccount(stableID: stableID) let legacyAccount = self.legacyCustomHeadersAccount(stableID: stableID) let canonicalJSON = KeychainStore.loadString(service: service, account: account) let legacyJSON = self.canSafelyReadLegacyRawStorageKey(stableID) ? KeychainStore.loadString(service: service, account: legacyAccount) : nil guard let json = canonicalJSON ?? legacyJSON, let data = json.data(using: .utf8), let headers = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] } if canonicalJSON == nil, KeychainStore.saveString(json, service: service, account: account) { _ = KeychainStore.delete(service: service, account: legacyAccount) } return GatewayCustomHeaders.sanitized(headers) } @discardableResult static func saveGatewayCustomHeaders(_ headers: [String: String], gatewayStableID: String) -> Bool { self.saveGatewayCustomHeaders( headers, gatewayStableID: gatewayStableID, service: self.gatewayCustomHeadersService) } @discardableResult static func saveGatewayCustomHeaders( _ headers: [String: String], gatewayStableID: String, service: String) -> Bool { let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) guard !stableID.isEmpty else { return false } let sanitized = GatewayCustomHeaders.sanitized(headers) guard !sanitized.isEmpty else { return self.clearGatewayCustomHeaders(gatewayStableID: stableID, service: service) } let account = self.customHeadersAccount(stableID: stableID) guard let data = try? JSONEncoder().encode(sanitized), let json = String(data: data, encoding: .utf8) else { return false } guard KeychainStore.saveString(json, service: service, account: account) else { return false } if self.canSafelyReadLegacyRawStorageKey(stableID) { _ = KeychainStore.delete( service: service, account: self.legacyCustomHeadersAccount(stableID: stableID)) } return true } /// Full onboarding reset is the explicit forget boundary for every gateway's proxy secrets. @discardableResult static func clearGatewayCustomHeaders() -> Bool { self.clearGatewayCustomHeaders(service: self.gatewayCustomHeadersService) } @discardableResult static func clearGatewayCustomHeaders(gatewayStableID: String) -> Bool { self.clearGatewayCustomHeaders( gatewayStableID: gatewayStableID, service: self.gatewayCustomHeadersService) } @discardableResult static func clearGatewayCustomHeaders(gatewayStableID: String, service: String) -> Bool { let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID) guard !stableID.isEmpty else { return false } let account = self.customHeadersAccount(stableID: stableID) let canonicalDeleted = KeychainStore.delete(service: service, account: account) var legacyCleared = true if self.canSafelyReadLegacyRawStorageKey(stableID) { let legacyAccount = self.legacyCustomHeadersAccount(stableID: stableID) let legacyDeleted = KeychainStore.delete(service: service, account: legacyAccount) legacyCleared = legacyDeleted || KeychainStore.loadString(service: service, account: legacyAccount) == nil } let canonicalCleared = canonicalDeleted || KeychainStore.loadString(service: service, account: account) == nil return canonicalCleared && legacyCleared } @discardableResult static func clearGatewayCustomHeaders(service: String) -> Bool { KeychainStore.deleteAll(service: service) } private static func customHeadersAccount(stableID: String) -> String { "customHeaders.v2.\(GatewayStableIdentifier.storageComponent(stableID)!)" } private static func legacyCustomHeadersAccount(stableID: String) -> String { "customHeaders.\(stableID)" } @discardableResult static func migrateProvenRelayCredentials( instanceId: String, gatewayStableID: String, token: String?, password: String?) -> Bool { let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard let stableID = GatewayStableIdentifier.exact(gatewayStableID), !trimmedInstanceID.isEmpty else { return false } let legacyAccounts = [ self.gatewayTokenAccount(instanceId: trimmedInstanceID), self.gatewayBootstrapTokenAccount(instanceId: trimmedInstanceID), self.gatewayPasswordAccount(instanceId: trimmedInstanceID), ] let hasLegacyCredentials = legacyAccounts.contains { account in self.normalizedCredential(KeychainStore.loadString( service: self.gatewayService, account: account)) != nil } guard hasLegacyCredentials else { return true } // A canonical bundle already owns the fields atomically. Never replace it with // older relay data merely because legacy per-field entries still exist. if self.loadGatewayCredentialBundle( instanceId: trimmedInstanceID, gatewayStableID: stableID) != nil { self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID) return true } let relayToken = self.normalizedCredential(token) let relayPassword = self.normalizedCredential(password) guard relayToken != nil || relayPassword != nil else { self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID) return true } // Relay config is written only after a successful connection and therefore proves // both the credential values and their gateway owner. Preserve it before cleanup. return self.saveGatewayCredentials( token: relayToken, bootstrapToken: nil, password: relayPassword, gatewayStableID: stableID, suppressStoredDeviceAuth: false, instanceId: trimmedInstanceID) } static func saveLegacyGatewayTokenForMigrationTest(_ token: String, instanceId: String) { _ = KeychainStore.saveString( token, service: self.gatewayService, account: self.gatewayTokenAccount(instanceId: instanceId)) } private struct LegacyLastGatewayConnectionData: Codable { var kind: GatewayRegistryEntry.Kind var stableID: String var useTLS: Bool var host: String? var port: Int? } static func loadTalkProviderApiKey(provider: String) -> String? { guard let providerId = self.normalizedTalkProviderID(provider) else { return nil } let account = self.talkProviderApiKeyAccount(providerId: providerId) let value = KeychainStore.loadString( service: self.talkService, account: account)? .trimmingCharacters(in: .whitespacesAndNewlines) if value?.isEmpty == false { return value } return nil } static func loadGatewayRegistry() -> GatewayRegistry { guard let json = KeychainStore.loadString( service: self.gatewayService, account: self.gatewayRegistryAccount), let data = json.data(using: .utf8), let registry = try? JSONDecoder().decode(GatewayRegistry.self, from: data), registry.version == 1 else { return .empty } return self.normalizedGatewayRegistry(registry) } @discardableResult static func upsertGatewayRegistryEntry(_ entry: GatewayRegistryEntry) -> Bool { self.upsertGatewayRegistryEntry(entry, activate: false) } @discardableResult static func upsertGatewayRegistryEntry(_ entry: GatewayRegistryEntry, activate: Bool) -> Bool { guard let normalized = self.normalizedGatewayRegistryEntry(entry) else { return false } var registry = self.loadGatewayRegistry() if let index = registry.entries.firstIndex(where: { GatewayStableIdentifier.matches($0.stableID, normalized.stableID) }) { var replacement = normalized if replacement.lastConnectedAtMs == nil { replacement.lastConnectedAtMs = registry.entries[index].lastConnectedAtMs } registry.entries[index] = replacement } else { registry.entries.append(normalized) } if activate { registry.activeStableID = normalized.stableID } return self.saveGatewayRegistry(registry) } @discardableResult static func setActiveGateway(stableID: String) -> Bool { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false } var registry = self.loadGatewayRegistry() guard let storedID = registry.entries.first(where: { GatewayStableIdentifier.matches($0.stableID, stableID) })?.stableID else { return false } registry.activeStableID = storedID return self.saveGatewayRegistry(registry) } @discardableResult static func markGatewayConnected(stableID: String, atMs: Int) -> Bool { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false } var registry = self.loadGatewayRegistry() guard let index = registry.entries.firstIndex(where: { GatewayStableIdentifier.matches($0.stableID, stableID) }) else { return false } registry.entries[index].lastConnectedAtMs = atMs return self.saveGatewayRegistry(registry) } @discardableResult static func removeGatewayRegistryEntry(stableID: String) -> Bool { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false } var registry = self.loadGatewayRegistry() registry.entries.removeAll { GatewayStableIdentifier.matches($0.stableID, stableID) } if GatewayStableIdentifier.matches(registry.activeStableID, stableID) { registry.activeStableID = nil } return self.saveGatewayRegistry(registry) } static func activeGatewayEntry() -> GatewayRegistryEntry? { let registry = self.loadGatewayRegistry() guard let activeStableID = registry.activeStableID else { return nil } return registry.entries.first { GatewayStableIdentifier.matches($0.stableID, activeStableID) } } static func clearLegacyGatewaySelectors(stableID: String) { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return } let defaults = UserDefaults.standard for (defaultsKey, account) in [ (self.preferredGatewayStableIDDefaultsKey, self.preferredGatewayStableIDAccount), (self.lastDiscoveredGatewayStableIDDefaultsKey, self.lastDiscoveredGatewayStableIDAccount), ] { let defaultsValue = defaults.string(forKey: defaultsKey) if GatewayStableIdentifier.matches(defaultsValue, stableID) { defaults.removeObject(forKey: defaultsKey) } let keychainValue = KeychainStore.loadString(service: self.gatewayService, account: account) if GatewayStableIdentifier.matches(keychainValue, stableID) { _ = KeychainStore.delete(service: self.gatewayService, account: account) } } } static func clearGatewayRegistry(defaults: UserDefaults = .standard) { _ = KeychainStore.delete(service: self.gatewayService, account: self.gatewayRegistryAccount) _ = KeychainStore.delete(service: self.gatewayService, account: self.lastGatewayConnectionAccount) self.removeLastGatewayDefaults(defaults) } private static func saveGatewayRegistry(_ registry: GatewayRegistry) -> Bool { let normalized = self.normalizedGatewayRegistry(registry) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] guard let data = try? encoder.encode(normalized), let json = String(data: data, encoding: .utf8) else { return false } return KeychainStore.saveString( json, service: self.gatewayService, account: self.gatewayRegistryAccount) } private static func normalizedGatewayRegistry(_ registry: GatewayRegistry) -> GatewayRegistry { var seen = Set() let entries = registry.entries .compactMap(self.normalizedGatewayRegistryEntry) .filter { entry in guard let key = GatewayStableIdentifier.key(entry.stableID) else { return false } return seen.insert(key).inserted } .sorted { lhs, rhs in if lhs.name != rhs.name { return lhs.name < rhs.name } return GatewayStableIdentifier.sortsBefore(lhs.stableID, rhs.stableID) } let activeStableID = registry.activeStableID.flatMap { activeID in entries.first(where: { GatewayStableIdentifier.matches($0.stableID, activeID) })?.stableID } return GatewayRegistry(version: 1, activeStableID: activeStableID, entries: entries) } private static func normalizedGatewayRegistryEntry( _ entry: GatewayRegistryEntry) -> GatewayRegistryEntry? { guard let stableID = GatewayStableIdentifier.exact(entry.stableID) else { return nil } let name = entry.name.trimmingCharacters(in: .whitespacesAndNewlines) if entry.kind == .manual { let host = entry.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !host.isEmpty, let port = entry.port, (1...65535).contains(port) else { return nil } return GatewayRegistryEntry( stableID: stableID, kind: .manual, name: name.isEmpty ? "\(host):\(port)" : name, host: host, port: port, useTLS: entry.useTLS, lastConnectedAtMs: entry.lastConnectedAtMs) } return GatewayRegistryEntry( stableID: stableID, kind: .discovered, name: name.isEmpty ? stableID : name, host: nil, port: nil, useTLS: entry.useTLS, lastConnectedAtMs: entry.lastConnectedAtMs) } private static func migrateGatewayRegistryIfNeeded(defaults: UserDefaults = .standard) { if KeychainStore.loadString(service: self.gatewayService, account: self.gatewayRegistryAccount) != nil { _ = KeychainStore.delete(service: self.gatewayService, account: self.lastGatewayConnectionAccount) self.removeLastGatewayDefaults(defaults) return } let legacy = self.loadLegacyLastGatewayConnection(defaults: defaults) guard let entry = legacy.flatMap(self.gatewayRegistryEntry(from:)) else { return } let registry = GatewayRegistry(activeStableID: entry.stableID, entries: [entry]) guard self.saveGatewayRegistry(registry) else { return } _ = KeychainStore.delete(service: self.gatewayService, account: self.lastGatewayConnectionAccount) self.removeLastGatewayDefaults(defaults) } private static func loadLegacyLastGatewayConnection( defaults: UserDefaults) -> LegacyLastGatewayConnectionData? { if let json = KeychainStore.loadString( service: self.gatewayService, account: self.lastGatewayConnectionAccount), let data = json.data(using: .utf8), let stored = try? JSONDecoder().decode(LegacyLastGatewayConnectionData.self, from: data) { return stored } guard let stableID = GatewayStableIdentifier.exact( defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)) else { return nil } let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let kind = GatewayRegistryEntry.Kind(rawValue: kindRaw) ?? .manual return LegacyLastGatewayConnectionData( kind: kind, stableID: stableID, useTLS: defaults.bool(forKey: self.lastGatewayTlsDefaultsKey), host: kind == .manual ? defaults.string(forKey: self.lastGatewayHostDefaultsKey) : nil, port: kind == .manual ? defaults.object(forKey: self.lastGatewayPortDefaultsKey) as? Int : nil) } private static func gatewayRegistryEntry( from legacy: LegacyLastGatewayConnectionData) -> GatewayRegistryEntry? { self.normalizedGatewayRegistryEntry(GatewayRegistryEntry( stableID: legacy.stableID, kind: legacy.kind, name: legacy.kind == .manual ? "\(legacy.host ?? ""):\(legacy.port ?? 0)" : legacy.stableID, host: legacy.host, port: legacy.port, useTLS: legacy.useTLS, lastConnectedAtMs: nil)) } private static func removeLastGatewayDefaults(_ defaults: UserDefaults) { defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey) defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey) defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey) } static func deleteGatewayCredentials(instanceId: String, stableID: String) { let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard let stableID = GatewayStableIdentifier.exact(stableID), !trimmed.isEmpty else { return } _ = KeychainStore.delete( service: self.gatewayService, account: self.gatewayCredentialBundleAccount(instanceId: trimmed, stableID: stableID)) self.deleteLegacyScopedCredentialBundleIfOwned(instanceId: trimmed, stableID: stableID) } static func deleteAllGatewayCredentials(instanceId: String) { let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } _ = KeychainStore.deleteAccounts( service: self.gatewayService, accountPrefix: self.legacyGatewayCredentialBundleAccount(instanceId: trimmed) + ".") _ = KeychainStore.delete( service: self.gatewayService, account: self.legacyGatewayCredentialBundleAccount(instanceId: trimmed)) self.deleteLegacyGatewayCredentials(instanceId: trimmed) } static func loadGatewayClientIdOverride(stableID: String) -> String? { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return nil } let defaults = UserDefaults.standard let key = self.gatewayDefaultsKey(prefix: self.clientIdOverrideDefaultsPrefix, stableID: stableID) let legacyKey = self.clientIdOverrideDefaultsPrefix + stableID let value = (defaults.string(forKey: key) ?? (self.canSafelyReadLegacyRawStorageKey(stableID) ? defaults.string(forKey: legacyKey) : nil))? .trimmingCharacters(in: .whitespacesAndNewlines) if value?.isEmpty == false { if defaults.string(forKey: key) == nil { defaults.set(value, forKey: key) defaults.removeObject(forKey: legacyKey) } return value } return nil } static func saveGatewayClientIdOverride(stableID: String, clientId: String?) { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return } let key = self.gatewayDefaultsKey(prefix: self.clientIdOverrideDefaultsPrefix, stableID: stableID) let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmedClientId.isEmpty { UserDefaults.standard.removeObject(forKey: key) } else { UserDefaults.standard.set(trimmedClientId, forKey: key) } if self.canSafelyReadLegacyRawStorageKey(stableID) { UserDefaults.standard.removeObject(forKey: self.clientIdOverrideDefaultsPrefix + stableID) } } static func loadGatewaySelectedAgentId(stableID: String) -> String? { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return nil } let defaults = UserDefaults.standard let key = self.gatewayDefaultsKey(prefix: self.selectedAgentDefaultsPrefix, stableID: stableID) let legacyKey = self.selectedAgentDefaultsPrefix + stableID let value = (defaults.string(forKey: key) ?? (self.canSafelyReadLegacyRawStorageKey(stableID) ? defaults.string(forKey: legacyKey) : nil))? .trimmingCharacters(in: .whitespacesAndNewlines) if value?.isEmpty == false { if defaults.string(forKey: key) == nil { defaults.set(value, forKey: key) defaults.removeObject(forKey: legacyKey) } return value } return nil } static func saveGatewaySelectedAgentId(stableID: String, agentId: String?) { guard let stableID = GatewayStableIdentifier.exact(stableID) else { return } let key = self.gatewayDefaultsKey(prefix: self.selectedAgentDefaultsPrefix, stableID: stableID) let trimmedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmedAgentId.isEmpty { UserDefaults.standard.removeObject(forKey: key) } else { UserDefaults.standard.set(trimmedAgentId, forKey: key) } if self.canSafelyReadLegacyRawStorageKey(stableID) { UserDefaults.standard.removeObject(forKey: self.selectedAgentDefaultsPrefix + stableID) } } private static func gatewayDefaultsKey(prefix: String, stableID: String) -> String { "\(prefix)v2.\(GatewayStableIdentifier.storageComponent(stableID)!)" } private static func gatewayTokenAccount(instanceId: String) -> String { "gateway-token.\(instanceId)" } private static func gatewayBootstrapTokenAccount(instanceId: String) -> String { "gateway-bootstrap-token.\(instanceId)" } private static func gatewayPasswordAccount(instanceId: String) -> String { "gateway-password.\(instanceId)" } private static func legacyGatewayCredentialBundleAccount(instanceId: String) -> String { "gateway-credentials.\(instanceId)" } private static func gatewayCredentialBundleAccount(instanceId: String, stableID: String) -> String { "gateway-credentials.\(instanceId).v2.\(GatewayStableIdentifier.storageComponent(stableID)!)" } private static func legacyScopedGatewayCredentialBundleAccount( instanceId: String, stableID: String) -> String { "gateway-credentials.\(instanceId).\(stableID)" } private static func loadGatewayCredentialBundle( instanceId: String, gatewayStableID: String) -> GatewayCredentialBundle? { let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard let stableID = GatewayStableIdentifier.exact(gatewayStableID), !trimmedInstanceID.isEmpty else { return nil } let account = self.gatewayCredentialBundleAccount( instanceId: trimmedInstanceID, stableID: stableID) let legacyAccount = self.legacyScopedGatewayCredentialBundleAccount( instanceId: trimmedInstanceID, stableID: stableID) let canonicalJSON = KeychainStore.loadString(service: self.gatewayService, account: account) guard let json = canonicalJSON ?? KeychainStore.loadString( service: self.gatewayService, account: legacyAccount), let data = json.data(using: .utf8), let decoded = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data) else { return nil } guard let decodedStableID = GatewayStableIdentifier.exact(decoded.gatewayStableID), GatewayStableIdentifier.matches(decodedStableID, stableID) else { return nil } let bundle = GatewayCredentialBundle( gatewayStableID: decodedStableID, suppressStoredDeviceAuth: decoded.suppressStoredDeviceAuth, token: self.normalizedCredential(decoded.token), bootstrapToken: self.normalizedCredential(decoded.bootstrapToken), password: self.normalizedCredential(decoded.password)) if canonicalJSON == nil, let migratedData = try? JSONEncoder().encode(bundle), let migratedJSON = String(data: migratedData, encoding: .utf8), KeychainStore.saveString(migratedJSON, service: self.gatewayService, account: account) { _ = KeychainStore.delete(service: self.gatewayService, account: legacyAccount) } return bundle } private static func migrateGatewayCredentialBundleIfNeeded(instanceId: String) { let instanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceID.isEmpty else { return } let legacyAccount = self.legacyGatewayCredentialBundleAccount(instanceId: instanceID) guard let json = KeychainStore.loadString(service: self.gatewayService, account: legacyAccount), let data = json.data(using: .utf8), let legacy = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data) else { return } guard let stableID = GatewayStableIdentifier.exact(legacy.gatewayStableID) else { return } let scopedAccount = self.gatewayCredentialBundleAccount(instanceId: instanceID, stableID: stableID) let scopedExists = KeychainStore.loadString(service: self.gatewayService, account: scopedAccount) != nil guard scopedExists || KeychainStore.saveString( json, service: self.gatewayService, account: scopedAccount) else { return } _ = KeychainStore.delete(service: self.gatewayService, account: legacyAccount) self.deleteLegacyGatewayCredentials(instanceId: instanceID) } private static func deleteLegacyScopedCredentialBundleIfOwned( instanceId: String, stableID: String) { let account = self.legacyScopedGatewayCredentialBundleAccount( instanceId: instanceId, stableID: stableID) guard let json = KeychainStore.loadString(service: self.gatewayService, account: account), let data = json.data(using: .utf8), let bundle = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data), GatewayStableIdentifier.matches(bundle.gatewayStableID, stableID) else { return } _ = KeychainStore.delete(service: self.gatewayService, account: account) } private static func canSafelyReadLegacyRawStorageKey(_ stableID: String) -> Bool { // Legacy header/default records do not embed their owner. Only ASCII keys outside // the v2 namespace can be attributed without aliasing another owner's encoded key. !stableID.hasPrefix("v2.") && stableID.unicodeScalars.allSatisfy(\.isASCII) } private static func normalizedCredential(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } private static func deleteLegacyGatewayCredentials(instanceId: String) { _ = KeychainStore.delete( service: self.gatewayService, account: self.gatewayTokenAccount(instanceId: instanceId)) _ = KeychainStore.delete( service: self.gatewayService, account: self.gatewayBootstrapTokenAccount(instanceId: instanceId)) _ = KeychainStore.delete( service: self.gatewayService, account: self.gatewayPasswordAccount(instanceId: instanceId)) _ = KeychainStore.delete( service: self.gatewayService, account: "gateway-credential-metadata.\(instanceId)") } private static func talkProviderApiKeyAccount(providerId: String) -> String { self.talkProviderApiKeyAccountPrefix + providerId } private static func normalizedTalkProviderID(_ provider: String) -> String? { let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return trimmed.isEmpty ? nil : trimmed } private static func ensureStableInstanceID() { let defaults = UserDefaults.standard if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines), !existing.isEmpty { if self.loadStableInstanceID() == nil { self.saveStableInstanceID(existing) } return } if let stored = self.loadStableInstanceID(), !stored.isEmpty { defaults.set(stored, forKey: self.instanceIdDefaultsKey) return } let fresh = UUID().uuidString self.saveStableInstanceID(fresh) defaults.set(fresh, forKey: self.instanceIdDefaultsKey) } private static func ensurePreferredGatewayStableID() { let defaults = UserDefaults.standard if let existing = GatewayStableIdentifier.exact( defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)) { if self.loadPreferredGatewayStableID() == nil { self.savePreferredGatewayStableID(existing) } return } if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty { defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey) } } private static func ensureLastDiscoveredGatewayStableID() { let defaults = UserDefaults.standard if let existing = GatewayStableIdentifier.exact( defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)) { if self.loadLastDiscoveredGatewayStableID() == nil { self.saveLastDiscoveredGatewayStableID(existing) } return } if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty { defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) } } } enum GatewayDiagnostics { struct ScopedLogger { private let prefix: String fileprivate init(prefix: String) { self.prefix = prefix } func stage(_ message: String) { GatewayDiagnostics.log("\(self.prefix): \(GatewayDiagnostics.sanitizeScopedMessage(message))") } func skipped(_ reason: String) { self.stage("registration skipped reason=\(reason)") } func failed(_ stage: String, error: Error) { let nsError = error as NSError let errorType = String(reflecting: type(of: error)) self .stage( "\(stage) failed errorType=\(errorType) domain=\(nsError.domain) code=\(nsError.code)") } } private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag") private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics") private static let maxLogBytes: Int64 = 512 * 1024 private static let keepLogBytes: Int64 = 256 * 1024 private static let logSizeCheckEveryWrites = 50 private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0) private static let maxScopedMessageCharacters = 320 /// Keep relay diagnostics stage-based. Push tokens, relay grants, proofs, /// receipts, signed payloads, and handles must never enter this cache log. static let pushRelay = ScopedLogger(prefix: "push relay") private static func sanitizeScopedMessage(_ value: String) -> String { let collapsed = value .replacingOccurrences(of: "\r", with: " ") .replacingOccurrences(of: "\n", with: " ") .replacingOccurrences(of: "\t", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) guard collapsed.count > self.maxScopedMessageCharacters else { return collapsed } let end = collapsed.index(collapsed.startIndex, offsetBy: self.maxScopedMessageCharacters) return String(collapsed[.. String { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.string(from: Date()) } private static var fileURL: URL? { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent("openclaw-gateway.log") } private static func truncateLogIfNeeded(url: URL) { guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let sizeNumber = attrs[.size] as? NSNumber else { return } let size = sizeNumber.int64Value guard size > self.maxLogBytes else { return } do { let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } let start = max(Int64(0), size - self.keepLogBytes) try handle.seek(toOffset: UInt64(start)) var tail = try handle.readToEnd() ?? Data() // If we truncated mid-line, drop the first partial line so logs remain readable. if start > 0, let nl = tail.firstIndex(of: 10) { let next = tail.index(after: nl) if next < tail.endIndex { tail = tail.suffix(from: next) } else { tail = Data() } } try tail.write(to: url, options: .atomic) } catch { // Best-effort only. } } private static func appendToLog(url: URL, data: Data) { if FileManager.default.fileExists(atPath: url.path) { if let handle = try? FileHandle(forWritingTo: url) { defer { try? handle.close() } _ = try? handle.seekToEnd() try? handle.write(contentsOf: data) } } else { try? data.write(to: url, options: .atomic) } } private static func applyFileProtection(url: URL) { try? FileManager.default.setAttributes( [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: url.path) } static func bootstrap() { guard let url = fileURL else { return } self.queue.async { self.truncateLogIfNeeded(url: url) let timestamp = self.isoTimestamp() let line = "[\(timestamp)] gateway diagnostics started\n" if let data = line.data(using: .utf8) { self.appendToLog(url: url, data: data) self.applyFileProtection(url: url) } } } static func log(_ message: String) { let timestamp = self.isoTimestamp() let line = "[\(timestamp)] \(message)" self.logger.info("\(line, privacy: .public)") guard let url = fileURL else { return } self.queue.async { let shouldTruncate = self.logWritesSinceCheck.withLock { count in count += 1 if count >= self.logSizeCheckEveryWrites { count = 0 return true } return false } if shouldTruncate { self.truncateLogIfNeeded(url: url) } let entry = line + "\n" if let data = entry.data(using: .utf8) { self.appendToLog(url: url, data: data) } } } }