From c2db63c5fac40dc4f32de1cd860bd229379ef2a7 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 12 May 2026 12:51:18 -0400 Subject: [PATCH] Import Gemini credentials into shared Keychain store --- Config/ContextPanel.entitlements | 4 + Config/ContextPanelAppStore.entitlements | 4 + Config/ContextPanelRefreshAgent.entitlements | 4 + ...textPanelRefreshAgentAppStore.entitlements | 4 + .../AccountConfigurationStore.swift | 7 +- .../GeminiCodeAssistQuota.swift | 17 ++- .../ProviderCredentialStore.swift | 123 ++++++++++++++++++ .../SnapshotRefreshService.swift | 16 ++- .../ContextPanelPreviewApp.swift | 21 +++ .../ProviderConnectorTests.swift | 48 +++++++ 10 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 Sources/ContextPanelCore/ProviderCredentialStore.swift diff --git a/Config/ContextPanel.entitlements b/Config/ContextPanel.entitlements index 54c4546..deeff22 100644 --- a/Config/ContextPanel.entitlements +++ b/Config/ContextPanel.entitlements @@ -9,5 +9,9 @@ group.com.shinycomputers.contextpanel MM5YXC7T6E.group.com.shinycomputers.contextpanel + keychain-access-groups + + MM5YXC7T6E.com.shinycomputers.contextpanel.credentials + diff --git a/Config/ContextPanelAppStore.entitlements b/Config/ContextPanelAppStore.entitlements index 4d691ab..48908dc 100644 --- a/Config/ContextPanelAppStore.entitlements +++ b/Config/ContextPanelAppStore.entitlements @@ -9,5 +9,9 @@ group.com.shinycomputers.contextpanel MM5YXC7T6E.group.com.shinycomputers.contextpanel + keychain-access-groups + + MM5YXC7T6E.com.shinycomputers.contextpanel.credentials + diff --git a/Config/ContextPanelRefreshAgent.entitlements b/Config/ContextPanelRefreshAgent.entitlements index 54c4546..deeff22 100644 --- a/Config/ContextPanelRefreshAgent.entitlements +++ b/Config/ContextPanelRefreshAgent.entitlements @@ -9,5 +9,9 @@ group.com.shinycomputers.contextpanel MM5YXC7T6E.group.com.shinycomputers.contextpanel + keychain-access-groups + + MM5YXC7T6E.com.shinycomputers.contextpanel.credentials + diff --git a/Config/ContextPanelRefreshAgentAppStore.entitlements b/Config/ContextPanelRefreshAgentAppStore.entitlements index 4d691ab..48908dc 100644 --- a/Config/ContextPanelRefreshAgentAppStore.entitlements +++ b/Config/ContextPanelRefreshAgentAppStore.entitlements @@ -9,5 +9,9 @@ group.com.shinycomputers.contextpanel MM5YXC7T6E.group.com.shinycomputers.contextpanel + keychain-access-groups + + MM5YXC7T6E.com.shinycomputers.contextpanel.credentials + diff --git a/Sources/ContextPanelCore/AccountConfigurationStore.swift b/Sources/ContextPanelCore/AccountConfigurationStore.swift index bebd819..2e6d384 100644 --- a/Sources/ContextPanelCore/AccountConfigurationStore.swift +++ b/Sources/ContextPanelCore/AccountConfigurationStore.swift @@ -168,6 +168,7 @@ public struct AccountConfigurationStore: Sendable { public enum AccountConnectorFactory { public static func connectors( from document: AccountConfigurationDocument, + credentialStore: (any ProviderCredentialStore)? = nil, environment: [String: String] = ProcessInfo.processInfo.environment, geminiMetadataFileLoader: @escaping @Sendable (String) throws -> String = { path in try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8) @@ -204,7 +205,7 @@ public enum AccountConnectorFactory { accountName: account.displayName, clientID: metadata.clientID, clientSecret: metadata.clientSecret - )]) + )], credentialStore: credentialStore, credentialKey: geminiCredentialKey(for: account)) case .claudeLocalStatus: return ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration( accountName: account.displayName, @@ -230,6 +231,10 @@ public enum AccountConnectorFactory { return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret) } + public static func geminiCredentialKey(for account: LocalProviderAccountConfiguration) -> ProviderCredentialKey { + ProviderCredentialKey(provider: .google, accountID: account.id, kind: "gemini-oauth") + } + private static func codexAuthPath(for authPath: String) -> String { let expanded = NSString(string: authPath).expandingTildeInPath let url = URL(fileURLWithPath: expanded) diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift index e134c98..89a066e 100644 --- a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift +++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift @@ -215,17 +215,23 @@ public struct GeminiCodeAssistConnector: ProviderConnector { private let accounts: [GeminiAccountConfiguration] private let httpClient: any ConnectorHTTPClient private let fileLoader: @Sendable (String) throws -> Data + private let credentialStore: (any ProviderCredentialStore)? + private let credentialKey: ProviderCredentialKey? public init( accounts: [GeminiAccountConfiguration], httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(), fileLoader: @escaping @Sendable (String) throws -> Data = { path in try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)) - } + }, + credentialStore: (any ProviderCredentialStore)? = nil, + credentialKey: ProviderCredentialKey? = nil ) { self.accounts = accounts self.httpClient = httpClient self.fileLoader = fileLoader + self.credentialStore = credentialStore + self.credentialKey = credentialKey } public func refresh(now: Date) async -> ConnectorRefreshResult { @@ -241,7 +247,7 @@ public struct GeminiCodeAssistConnector: ProviderConnector { let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath) do { - let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: try fileLoader(account.authPath)) + let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: try credentialData(for: account)) let accessToken = try await refreshedAccessToken(credentials: credentials, account: account) let loadResponse = try await loadCodeAssist(accessToken: accessToken, endpoint: account.codeAssistEndpoint) guard let project = loadResponse.cloudaicompanionProject, !project.isEmpty else { @@ -276,6 +282,13 @@ public struct GeminiCodeAssistConnector: ProviderConnector { } } + private func credentialData(for account: GeminiAccountConfiguration) throws -> Data { + if let credentialStore, let credentialKey, let data = try credentialStore.data(for: credentialKey) { + return data + } + return try fileLoader(account.authPath) + } + private func refreshedAccessToken(credentials: GeminiOAuthCredentials, account: GeminiAccountConfiguration) async throws -> String { guard let refreshToken = credentials.refreshToken, !refreshToken.isEmpty else { throw ConnectorError.invalidAuth("Gemini OAuth file does not contain a refresh token") diff --git a/Sources/ContextPanelCore/ProviderCredentialStore.swift b/Sources/ContextPanelCore/ProviderCredentialStore.swift new file mode 100644 index 0000000..a8b2dc5 --- /dev/null +++ b/Sources/ContextPanelCore/ProviderCredentialStore.swift @@ -0,0 +1,123 @@ +import Foundation +import Security + +public protocol ProviderCredentialStore: Sendable { + func data(for key: ProviderCredentialKey) throws -> Data? + func store(_ data: Data, for key: ProviderCredentialKey) throws + func remove(_ key: ProviderCredentialKey) throws +} + +public struct ProviderCredentialKey: Hashable, Sendable { + public let provider: Provider + public let accountID: String + public let kind: String + + public init(provider: Provider, accountID: String, kind: String) { + self.provider = provider + self.accountID = accountID + self.kind = kind + } + + var service: String { + "com.shinycomputers.contextpanel.\(provider.rawValue).\(kind)" + } + + var account: String { + accountID + } +} + +public enum ProviderCredentialStoreError: LocalizedError, Equatable, Sendable { + case keychainStatus(operation: String, status: OSStatus) + + public var errorDescription: String? { + switch self { + case let .keychainStatus(operation, status): + "Keychain \(operation) failed with status \(status)" + } + } +} + +public struct KeychainProviderCredentialStore: ProviderCredentialStore { + public static let defaultAccessGroup = "MM5YXC7T6E.com.shinycomputers.contextpanel.credentials" + + private let accessGroup: String? + + public init(accessGroup: String? = defaultAccessGroup) { + self.accessGroup = accessGroup + } + + public func data(for key: ProviderCredentialKey) throws -> Data? { + var query = baseQuery(for: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { + throw ProviderCredentialStoreError.keychainStatus(operation: "read", status: status) + } + return item as? Data + } + + public func store(_ data: Data, for key: ProviderCredentialKey) throws { + var query = baseQuery(for: key) + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw ProviderCredentialStoreError.keychainStatus(operation: "update", status: updateStatus) + } + + query.merge(attributes) { _, new in new } + let addStatus = SecItemAdd(query as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw ProviderCredentialStoreError.keychainStatus(operation: "store", status: addStatus) + } + } + + public func remove(_ key: ProviderCredentialKey) throws { + let status = SecItemDelete(baseQuery(for: key) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw ProviderCredentialStoreError.keychainStatus(operation: "delete", status: status) + } + } + + private func baseQuery(for key: ProviderCredentialKey) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: key.service, + kSecAttrAccount as String: key.account, + ] + if let accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } +} + +public final class InMemoryProviderCredentialStore: ProviderCredentialStore, @unchecked Sendable { + private var values: [ProviderCredentialKey: Data] + private let lock = NSLock() + + public init(values: [ProviderCredentialKey: Data] = [:]) { + self.values = values + } + + public func data(for key: ProviderCredentialKey) throws -> Data? { + lock.withLock { values[key] } + } + + public func store(_ data: Data, for key: ProviderCredentialKey) throws { + lock.withLock { values[key] = data } + } + + public func remove(_ key: ProviderCredentialKey) throws { + lock.withLock { _ = values.removeValue(forKey: key) } + } +} diff --git a/Sources/ContextPanelCore/SnapshotRefreshService.swift b/Sources/ContextPanelCore/SnapshotRefreshService.swift index 6270cc3..baac97e 100644 --- a/Sources/ContextPanelCore/SnapshotRefreshService.swift +++ b/Sources/ContextPanelCore/SnapshotRefreshService.swift @@ -158,10 +158,16 @@ public struct SnapshotRefreshRunner: Sendable { public struct SnapshotRefreshService: Sendable { private let accountStore: AccountConfigurationStore private let stores: SnapshotRefreshStores + private let credentialStore: (any ProviderCredentialStore)? - public init(accountStore: AccountConfigurationStore, stores: SnapshotRefreshStores) { + public init( + accountStore: AccountConfigurationStore, + stores: SnapshotRefreshStores, + credentialStore: (any ProviderCredentialStore)? = nil + ) { self.accountStore = accountStore self.stores = stores + self.credentialStore = credentialStore } public static func appDefault() -> SnapshotRefreshService { @@ -170,7 +176,8 @@ public struct SnapshotRefreshService: Sendable { configurationURL: ContextPanelLocations.accountConfigurationURL(), fallbackConfigurationURL: ContextPanelLocations.legacyAccountConfigurationURL() ), - stores: .appDefault() + stores: .appDefault(), + credentialStore: KeychainProviderCredentialStore() ) } @@ -188,7 +195,10 @@ public struct SnapshotRefreshService: Sendable { public func refresh(now: Date = Date()) async throws -> SnapshotRefreshOutcome { let accountDocument = accountStore.load(now: now).document - let connectors = AccountConnectorFactory.connectors(from: accountDocument) + let connectors = AccountConnectorFactory.connectors( + from: accountDocument, + credentialStore: credentialStore + ) let refreshResult = await ProviderConnectorRuntime(connectors: connectors).refreshAll(now: now) return try saveMerged(refreshResult: refreshResult, savedAt: now) } diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index e1d2bac..d20206f 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -89,6 +89,10 @@ struct SettingsPane: View { Text(account.isEnabled ? "Enabled" : "Disabled") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(account.isEnabled ? CPTheme.statusColor(.healthy) : CPTheme.tertiaryText) + if model.canImportCredentials(for: account) { + Button("Import") { model.importCredentials(for: account) } + .controlSize(.small) + } } Text(model.detailText(for: account)) .font(.system(size: 11)) @@ -217,6 +221,7 @@ final class SettingsPaneModel: ObservableObject { private let resetPrimerSettingsStore = ResetPrimerSettingsStore( settingsURL: ContextPanelLocations.resetPrimerSettingsURL(appGroupID: ContextPanelLocations.appGroupID) ) + private let credentialStore = KeychainProviderCredentialStore() private var widgetPreferenceStores: WidgetDisplayPreferencesStoreSet { WidgetDisplayPreferencesStoreSet(stores: [ @@ -295,6 +300,22 @@ final class SettingsPaneModel: ObservableObject { saveResetPrimerSettings(updated) } + func canImportCredentials(for account: LocalProviderAccountConfiguration) -> Bool { + account.connectorKind == .geminiCodeAssist && account.authPath != nil + } + + func importCredentials(for account: LocalProviderAccountConfiguration) { + guard let authPath = account.authPath else { return } + do { + let expanded = NSString(string: authPath).expandingTildeInPath + let data = try Data(contentsOf: URL(fileURLWithPath: expanded)) + try credentialStore.store(data, for: AccountConnectorFactory.geminiCredentialKey(for: account)) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + private func saveResetPrimerSettings(_ updated: ResetPrimerSettings) { do { try resetPrimerSettingsStore.save(updated) diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift index 9bffa8e..c0c144a 100644 --- a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift +++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift @@ -181,6 +181,54 @@ import Testing #expect(http.requests[2].body.flatMap { String(data: $0, encoding: .utf8) }?.contains("project-secret") == true) } +@Test func geminiConnectorPrefersImportedCredentials() async throws { + let importedCredentials = #"{"refresh_token":"imported-refresh"}"#.data(using: .utf8)! + let fileCredentials = #"{"refresh_token":"file-refresh"}"#.data(using: .utf8)! + let key = ProviderCredentialKey(provider: .google, accountID: "gemini", kind: "gemini-oauth") + let store = InMemoryProviderCredentialStore(values: [key: importedCredentials]) + let http = StubHTTPClient(responses: [ + ConnectorHTTPResponse(statusCode: 200, data: #"{"access_token":"access"}"#.data(using: .utf8)!), + ConnectorHTTPResponse(statusCode: 200, data: #"{"cloudaicompanionProject":"project","currentTier":{"name":"Gemini Code Assist"}}"#.data(using: .utf8)!), + ConnectorHTTPResponse(statusCode: 200, data: #"{"buckets":[]}"#.data(using: .utf8)!), + ]) + let connector = GeminiCodeAssistConnector( + accounts: [GeminiAccountConfiguration(authPath: "/tmp/gemini.json", accountName: "Gemini", clientID: "client", clientSecret: "secret")], + httpClient: http, + fileLoader: { _ in fileCredentials }, + credentialStore: store, + credentialKey: key + ) + + _ = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + let body = try #require(http.requests.first?.body.flatMap { String(data: $0, encoding: .utf8) }) + #expect(body.contains("imported-refresh")) + #expect(body.contains("file-refresh") == false) +} + +@Test func geminiConnectorFallsBackToAuthFileWhenImportedCredentialsAreMissing() async throws { + let fileCredentials = #"{"refresh_token":"file-refresh"}"#.data(using: .utf8)! + let key = ProviderCredentialKey(provider: .google, accountID: "gemini", kind: "gemini-oauth") + let store = InMemoryProviderCredentialStore() + let http = StubHTTPClient(responses: [ + ConnectorHTTPResponse(statusCode: 200, data: #"{"access_token":"access"}"#.data(using: .utf8)!), + ConnectorHTTPResponse(statusCode: 200, data: #"{"cloudaicompanionProject":"project","currentTier":{"name":"Gemini Code Assist"}}"#.data(using: .utf8)!), + ConnectorHTTPResponse(statusCode: 200, data: #"{"buckets":[]}"#.data(using: .utf8)!), + ]) + let connector = GeminiCodeAssistConnector( + accounts: [GeminiAccountConfiguration(authPath: "/tmp/gemini.json", accountName: "Gemini", clientID: "client", clientSecret: "secret")], + httpClient: http, + fileLoader: { _ in fileCredentials }, + credentialStore: store, + credentialKey: key + ) + + _ = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + let body = try #require(http.requests.first?.body.flatMap { String(data: $0, encoding: .utf8) }) + #expect(body.contains("file-refresh")) +} + @Test func claudeConnectorReportsUnknownLiveAllowanceFromLocalStatus() async throws { let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!