diff --git a/README.md b/README.md index edb55aa..0c601da 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,10 @@ block output when available and shows a clearly marked estimated 5-hour token window; that estimate is useful for "am I likely to run out soon?" but is not Anthropic's official subscription percentage. -For Gemini, use the OAuth client values from the locally installed Gemini CLI; -they are intentionally not checked into this repository. +For Google/Gemini, the app can use an Antigravity Keychain sign-in when +available. The probe path still uses OAuth client values from the locally +installed Gemini CLI; those values are intentionally not checked into this +repository. The probes call the same `ContextPanelCore` connectors the app will use, so passing probe output is also a smoke test for the production connector runtime. diff --git a/Resources/AppStore/Screenshots/final/context-panel-appstore-1-app.png b/Resources/AppStore/Screenshots/final/context-panel-appstore-1-app.png new file mode 100644 index 0000000..9316331 Binary files /dev/null and b/Resources/AppStore/Screenshots/final/context-panel-appstore-1-app.png differ diff --git a/Resources/AppStore/Screenshots/final/context-panel-appstore-2-widget.png b/Resources/AppStore/Screenshots/final/context-panel-appstore-2-widget.png new file mode 100644 index 0000000..69584c9 Binary files /dev/null and b/Resources/AppStore/Screenshots/final/context-panel-appstore-2-widget.png differ diff --git a/Resources/AppStore/Screenshots/previews/context-panel-appstore-1-app-800.png b/Resources/AppStore/Screenshots/previews/context-panel-appstore-1-app-800.png new file mode 100644 index 0000000..f277ee5 Binary files /dev/null and b/Resources/AppStore/Screenshots/previews/context-panel-appstore-1-app-800.png differ diff --git a/Resources/AppStore/Screenshots/previews/context-panel-appstore-2-widget-800.png b/Resources/AppStore/Screenshots/previews/context-panel-appstore-2-widget-800.png new file mode 100644 index 0000000..08465ef Binary files /dev/null and b/Resources/AppStore/Screenshots/previews/context-panel-appstore-2-widget-800.png differ diff --git a/Sources/ContextPanelCore/AccountConfigurationStore.swift b/Sources/ContextPanelCore/AccountConfigurationStore.swift index 04a8cda..bdf296e 100644 --- a/Sources/ContextPanelCore/AccountConfigurationStore.swift +++ b/Sources/ContextPanelCore/AccountConfigurationStore.swift @@ -223,7 +223,8 @@ public enum AccountConnectorFactory { geminiMetadataDirectoryLister: @escaping @Sendable (String) -> [String] = { path in let expanded = NSString(string: path).expandingTildeInPath return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? [] - } + }, + antigravityCredentialSource: AntigravityKeychainCredentialSource? = AntigravityKeychainCredentialSource() ) -> [any ProviderConnector] { return document.accounts.compactMap { account -> (any ProviderConnector)? in guard account.isEnabled else { return nil } @@ -268,13 +269,17 @@ public enum AccountConnectorFactory { fileExists: geminiMetadataFileExists, directoryLister: geminiMetadataDirectoryLister ) - guard let metadata = configuredMetadataValue ?? cachedMetadataValue ?? discoveredMetadata else { + let metadata = configuredMetadataValue + ?? cachedMetadataValue + ?? discoveredMetadata + ?? (antigravityCredentialSource?.hasCredentials() == true ? GeminiOAuthClientMetadata(clientID: "", clientSecret: "") : nil) + guard let metadata else { let expanded = NSString(string: authPath).expandingTildeInPath return FailingProviderConnector( provider: .google, accountID: ConnectorRedactor.localAccountID(provider: .google, path: expanded), accountName: account.displayName, - message: "Gemini OAuth client metadata could not be found. Reinstall Gemini CLI or configure Gemini OAuth client metadata." + message: "Google OAuth client metadata could not be found. Reinstall Gemini CLI, sign into Antigravity, or configure Gemini OAuth client metadata." ) } return GeminiCodeAssistConnector( @@ -286,7 +291,8 @@ public enum AccountConnectorFactory { )], fileLoader: authFileLoader, credentialStore: credentialStore, - credentialAccountID: account.id + credentialAccountID: account.id, + antigravityCredentialSource: antigravityCredentialSource ) case .claudeLocalStatus: return ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration( diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift index a69a6d2..2d0f541 100644 --- a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift +++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift @@ -170,15 +170,66 @@ public enum GeminiQuotaPayloadParser { public struct GeminiOAuthCredentials: Codable, Equatable, Sendable { public let accessToken: String? public let refreshToken: String? + public let expiresAt: Date? - public init(accessToken: String?, refreshToken: String?) { + public init(accessToken: String?, refreshToken: String?, expiresAt: Date? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken + self.expiresAt = expiresAt } enum CodingKeys: String, CodingKey { case accessToken = "access_token" case refreshToken = "refresh_token" + case expiresAt = "expiry" + } +} + +public struct AntigravityCredentialDecoder: Sendable { + private struct StoredCredential: Decodable { + let token: StoredToken + } + + private struct StoredToken: Decodable { + let accessToken: String? + let refreshToken: String? + let expiresAt: Date? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresAt = "expiry" + } + } + + public init() {} + + public func geminiOAuthCredentials(from data: Data) throws -> GeminiOAuthCredentials { + let payload = try decodedPayload(from: data) + let stored = try JSONDecoder.contextPanelISO8601.decode(StoredCredential.self, from: payload) + return GeminiOAuthCredentials( + accessToken: stored.token.accessToken, + refreshToken: stored.token.refreshToken, + expiresAt: stored.token.expiresAt + ) + } + + private func decodedPayload(from data: Data) throws -> Data { + let marker = Data("go-keyring-base64:".utf8) + if data.starts(with: marker) { + let encoded = data.dropFirst(marker.count) + guard let decoded = Data(base64Encoded: encoded) else { + throw ConnectorError.decodingFailure("Antigravity credential payload was not valid base64") + } + return decoded + } + return data + } +} + +public enum GeminiOAuthCredentialDecoder { + public static func credentials(from data: Data) throws -> GeminiOAuthCredentials { + try JSONDecoder.contextPanelISO8601.decode(GeminiOAuthCredentials.self, from: data) } } @@ -225,6 +276,37 @@ public struct GeminiAccountConfiguration: Equatable, Sendable { } } +private extension GeminiAccountConfiguration { + var hasOAuthClientMetadata: Bool { + !clientID.isEmpty && !clientSecret.isEmpty + } +} + +public struct AntigravityKeychainCredentialSource: Sendable { + public static let service = "gemini" + public static let accountID = "antigravity" + + private let credentialLoader: any ProviderCredentialLoading + private let decoder: AntigravityCredentialDecoder + + public init( + credentialLoader: any ProviderCredentialLoading = GenericPasswordCredentialLoader(service: Self.service), + decoder: AntigravityCredentialDecoder = AntigravityCredentialDecoder() + ) { + self.credentialLoader = credentialLoader + self.decoder = decoder + } + + public func loadCredentials() throws -> GeminiOAuthCredentials? { + guard let data = try credentialLoader.load(accountID: Self.accountID) else { return nil } + return try decoder.geminiOAuthCredentials(from: data) + } + + public func hasCredentials() -> Bool { + (try? loadCredentials()) != nil + } +} + public struct GeminiOAuthClientMetadata: Codable, Equatable, Sendable { public let clientID: String public let clientSecret: String @@ -499,6 +581,7 @@ public struct GeminiCodeAssistConnector: ProviderConnector { private let fileLoader: @Sendable (String) throws -> Data private let credentialStore: (any ProviderCredentialLoading)? private let credentialAccountID: String? + private let antigravityCredentialSource: AntigravityKeychainCredentialSource? public init( accounts: [GeminiAccountConfiguration], @@ -507,13 +590,15 @@ public struct GeminiCodeAssistConnector: ProviderConnector { try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)) }, credentialStore: (any ProviderCredentialLoading)? = nil, - credentialAccountID: String? = nil + credentialAccountID: String? = nil, + antigravityCredentialSource: AntigravityKeychainCredentialSource? = AntigravityKeychainCredentialSource() ) { self.accounts = accounts self.httpClient = httpClient self.fileLoader = fileLoader self.credentialStore = credentialStore self.credentialAccountID = credentialAccountID + self.antigravityCredentialSource = antigravityCredentialSource } public func refresh(now: Date) async -> ConnectorRefreshResult { @@ -529,7 +614,7 @@ public struct GeminiCodeAssistConnector: ProviderConnector { let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath) do { - let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: try credentialData(for: account)) + let credentials = try credentials(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 { @@ -577,7 +662,44 @@ public struct GeminiCodeAssistConnector: ProviderConnector { return try fileLoader(account.authPath) } + private func credentials(for account: GeminiAccountConfiguration) throws -> GeminiOAuthCredentials { + let localCredentialResult = Result { try credentialData(for: account) } + let localCredentials = localCredentialResult.successValue + .flatMap { try? GeminiOAuthCredentialDecoder.credentials(from: $0) } + + if account.hasOAuthClientMetadata, + let localCredentials, + localCredentials.refreshToken?.isEmpty == false + { + return localCredentials + } + if let antigravityCredentialSource { + do { + if let credentials = try antigravityCredentialSource.loadCredentials(), credentials.hasUsableToken { + return credentials + } + } catch { + throw ConnectorError.invalidAuth("Antigravity credential could not be read. Open Antigravity to refresh Google authentication, then refresh Context Panel again.") + } + } + if let localCredentials, localCredentials.refreshToken?.isEmpty == false { + return localCredentials + } + switch localCredentialResult { + case .success(let data): + return try GeminiOAuthCredentialDecoder.credentials(from: data) + case .failure(let error): + throw error + } + } + private func refreshedAccessToken(credentials: GeminiOAuthCredentials, account: GeminiAccountConfiguration) async throws -> String { + if let accessToken = credentials.validAccessToken() { + return accessToken + } + guard !account.clientID.isEmpty, !account.clientSecret.isEmpty else { + throw ConnectorError.invalidAuth("Antigravity access token is expired. Open Antigravity to refresh Google authentication, then refresh Context Panel again.") + } guard let refreshToken = credentials.refreshToken, !refreshToken.isEmpty else { throw ConnectorError.invalidAuth("Gemini OAuth file does not contain a refresh token") } @@ -846,6 +968,25 @@ private extension String { } } +private extension GeminiOAuthCredentials { + var hasUsableToken: Bool { + validAccessToken() != nil || refreshToken?.isEmpty == false + } + + func validAccessToken(now: Date = Date()) -> String? { + guard let accessToken, !accessToken.isEmpty else { return nil } + guard let expiresAt else { return nil } + return expiresAt.timeIntervalSince(now) > 60 ? accessToken : nil + } +} + +private extension Result { + var successValue: Success? { + guard case .success(let value) = self else { return nil } + return value + } +} + extension JSONDecoder { static var contextPanelISO8601: JSONDecoder { let decoder = JSONDecoder() diff --git a/Sources/ContextPanelCore/ProviderCredentialStore.swift b/Sources/ContextPanelCore/ProviderCredentialStore.swift index 83667b2..900bc9a 100644 --- a/Sources/ContextPanelCore/ProviderCredentialStore.swift +++ b/Sources/ContextPanelCore/ProviderCredentialStore.swift @@ -74,6 +74,39 @@ public struct ProviderCredentialStore: ProviderCredentialStoring { } } +public struct GenericPasswordCredentialLoader: ProviderCredentialLoading { + public enum LoadError: Error, Sendable { + case unhandledStatus(OSStatus) + } + + private let service: String + private let useDataProtectionKeychain: Bool + + public init(service: String, useDataProtectionKeychain: Bool = false) { + self.service = service + self.useDataProtectionKeychain = useDataProtectionKeychain + } + + public func load(accountID: String) throws -> Data? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: accountID, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if useDataProtectionKeychain { + query[kSecUseDataProtectionKeychain as String] = true + } + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw LoadError.unhandledStatus(status) } + return item as? Data + } +} + public final class InMemoryProviderCredentialStore: ProviderCredentialStoring, @unchecked Sendable { private var storage: [String: Data] @@ -98,3 +131,12 @@ extension ProviderCredentialStore.StoreError: LocalizedError { } } } + +extension GenericPasswordCredentialLoader.LoadError: LocalizedError { + public var errorDescription: String? { + switch self { + case .unhandledStatus(let status): + return "Keychain operation failed with status \(status)." + } + } +} diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index 2d54a4a..8921b39 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -171,7 +171,7 @@ struct SettingsPane: View { } if account.connectorKind == .geminiCodeAssist, account.isEnabled, !model.hasGeminiMetadata(for: account) { HStack(spacing: 8) { - Text("Background refresh needs Gemini CLI access") + Text("Background refresh can use Antigravity sign-in or Gemini CLI access") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(CPTheme.statusColor(.stale)) Button("Allow CLI Access") { model.authorizeGeminiMetadata(for: account) } @@ -821,7 +821,7 @@ final class SettingsPaneModel: ObservableObject { } return "Select the OpenAI CLI auth JSON file" case .geminiCodeAssist: - return "Select oauth_creds.json, then allow access to the Gemini CLI install for background refresh" + return "Sign into Antigravity, or select oauth_creds.json and allow Gemini CLI access" case .claudeLocalStatus: return "Claude reads Context Panel's statusline cache; no auth file selection is needed" case .claudeOAuthUsage: @@ -843,7 +843,7 @@ final class SettingsPaneModel: ObservableObject { func authorizeGeminiMetadata(for account: LocalProviderAccountConfiguration) { guard account.connectorKind == .geminiCodeAssist else { return } let panel = NSOpenPanel() - panel.message = "Allow access to Gemini CLI's bundle folder so Context Panel can refresh Gemini limits in the background. The folder is usually named bundle." + panel.message = "Allow access to Gemini CLI's bundle folder if Antigravity sign-in is not available for background refresh. The folder is usually named bundle." panel.prompt = "Allow Access" panel.canChooseFiles = true panel.canChooseDirectories = true diff --git a/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift index 8fb2711..f924e5b 100644 --- a/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift +++ b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift @@ -159,7 +159,8 @@ import Testing from: document, environment: [:], geminiMetadataFileLoader: { _ in "" }, - geminiMetadataFileExists: { _ in false } + geminiMetadataFileExists: { _ in false }, + antigravityCredentialSource: nil ) let missingMetadataResult = await ProviderConnectorRuntime(connectors: withoutGeminiMetadata).refreshAll(now: Date(timeIntervalSince1970: 0)) let withGeminiEnvironment = AccountConnectorFactory.connectors(from: document, environment: [ @@ -174,7 +175,7 @@ import Testing #expect(Set(withGeminiEnvironment.map(\.provider)) == [.openAI, .google]) } -@Test func accountConnectorFactoryReportsGeminiMetadataDiscoveryFailure() async { +@Test func accountConnectorFactoryRequiresGeminiMetadataWhenAntigravityCredentialIsMissing() async { let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [ LocalProviderAccountConfiguration( id: "gemini", @@ -192,7 +193,10 @@ import Testing environment: [:], useBundledGeminiMetadataFallback: false, geminiMetadataFileLoader: { _ in "" }, - geminiMetadataFileExists: { _ in false } + geminiMetadataFileExists: { _ in false }, + antigravityCredentialSource: AntigravityKeychainCredentialSource( + credentialLoader: InMemoryProviderCredentialStore(storage: [:]) + ) ) let result = await ProviderConnectorRuntime(connectors: connectors).refreshAll(now: Date(timeIntervalSince1970: 0)) @@ -200,7 +204,39 @@ import Testing #expect(result.reports.count == 1) #expect(result.reports[0].provider == .google) #expect(result.reports[0].status == .failure) - #expect(result.reports[0].errorMessage?.contains("Gemini OAuth client metadata") == true) + #expect(result.reports[0].errorMessage?.contains("Google OAuth client metadata") == true) +} + +@Test func accountConnectorFactoryAllowsMissingGeminiMetadataWhenAntigravityCredentialExists() async { + let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [ + LocalProviderAccountConfiguration( + id: "gemini", + provider: .google, + connectorKind: .geminiCodeAssist, + displayName: "Gemini", + authPath: "/tmp/gemini.json", + oauthClientIDEnvironmentName: "GEMINI_ID", + oauthClientSecretEnvironmentName: "GEMINI_SECRET" + ), + ]) + let antigravityPayload = #"{"auth_method":"consumer","token":{"access_token":"access-secret","expiry":"2099-05-22T17:00:00.000000000Z"}}"# + let storedAntigravityPayload = "go-keyring-base64:\(Data(antigravityPayload.utf8).base64EncodedString())" + + let connectors = AccountConnectorFactory.connectors( + from: document, + environment: [:], + useBundledGeminiMetadataFallback: false, + geminiMetadataFileLoader: { _ in "" }, + geminiMetadataFileExists: { _ in false }, + antigravityCredentialSource: AntigravityKeychainCredentialSource( + credentialLoader: InMemoryProviderCredentialStore(storage: [ + AntigravityKeychainCredentialSource.accountID: Data(storedAntigravityPayload.utf8), + ]) + ) + ) + + #expect(connectors.count == 1) + #expect(connectors[0].provider == .google) } @Test func accountConnectorFactoryFallsBackToGeminiDiscoveryForPartialMetadataEnvironment() async { diff --git a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift index 07e8f31..337a4b0 100644 --- a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift +++ b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift @@ -91,6 +91,41 @@ import Testing } } +@Test func antigravityCredentialDecoderReadsGoKeyringPayload() throws { + let payload = #"{"auth_method":"consumer","token":{"access_token":"access-secret","refresh_token":"refresh-secret","token_type":"Bearer","expiry":"2099-05-22T17:00:00.000000000Z"}}"# + let stored = "go-keyring-base64:\(Data(payload.utf8).base64EncodedString())" + + let credentials = try AntigravityCredentialDecoder().geminiOAuthCredentials(from: Data(stored.utf8)) + + #expect(credentials.accessToken == "access-secret") + #expect(credentials.refreshToken == "refresh-secret") + #expect(ContextPanelDateFormatting.string(from: try #require(credentials.expiresAt)) == "2099-05-22T17:00:00Z") +} + +@Test func antigravityCredentialSourceLoadsGeminiCredentialsFromKeychainPayload() throws { + let payload = #"{"auth_method":"consumer","token":{"refresh_token":"refresh-secret"}}"# + let stored = "go-keyring-base64:\(Data(payload.utf8).base64EncodedString())" + let source = AntigravityKeychainCredentialSource( + credentialLoader: InMemoryProviderCredentialStore(storage: [ + AntigravityKeychainCredentialSource.accountID: Data(stored.utf8), + ]) + ) + + let credentials = try source.loadCredentials() + + #expect(credentials?.refreshToken == "refresh-secret") +} + +@Test func geminiOAuthCredentialDecoderAcceptsStringExpiryInLocalCredentials() throws { + let payload = #"{"access_token":"access-secret","refresh_token":"refresh-secret","expiry":"2099-05-22T17:00:00.000000000Z"}"# + + let credentials = try GeminiOAuthCredentialDecoder.credentials(from: Data(payload.utf8)) + + #expect(credentials.accessToken == "access-secret") + #expect(credentials.refreshToken == "refresh-secret") + #expect(ContextPanelDateFormatting.string(from: try #require(credentials.expiresAt)) == "2099-05-22T17:00:00Z") +} + @Test func geminiConnectorSurfacesQuotaShapeDiagnostics() async throws { let credentials = #"{"refresh_token":"refresh-secret"}"#.data(using: .utf8)! let refresh = #"{"access_token":"access-secret"}"#.data(using: .utf8)! diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift index 6b7e3be..8e71bfd 100644 --- a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift +++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift @@ -210,6 +210,87 @@ import Testing #expect(http.requests[2].body.flatMap { String(data: $0, encoding: .utf8) }?.contains("project-secret") == true) } +@Test func geminiConnectorFallsBackToAntigravityKeychainCredentials() async throws { + let antigravityPayload = #"{"auth_method":"consumer","token":{"access_token":"antigravity-access","refresh_token":"antigravity-refresh","expiry":"2099-05-22T17:00:00.000000000Z"}}"# + let storedAntigravityPayload = "go-keyring-base64:\(Data(antigravityPayload.utf8).base64EncodedString())" + let load = #"{"cloudaicompanionProject":"project-secret"}"#.data(using: .utf8)! + let quota = #"{"buckets":[{"modelId":"gemini-3-flash-preview","remainingFraction":0.5}]}"#.data(using: .utf8)! + let http = StubHTTPClient(responses: [ + ConnectorHTTPResponse(statusCode: 200, data: load), + ConnectorHTTPResponse(statusCode: 200, data: quota), + ]) + let antigravitySource = AntigravityKeychainCredentialSource( + credentialLoader: InMemoryProviderCredentialStore(storage: [ + AntigravityKeychainCredentialSource.accountID: Data(storedAntigravityPayload.utf8), + ]) + ) + let connector = GeminiCodeAssistConnector( + accounts: [GeminiAccountConfiguration(authPath: "/tmp/missing-gemini.json", accountName: "Google", clientID: "client", clientSecret: "secret")], + httpClient: http, + fileLoader: { _ in throw ConnectorError.invalidAuth("missing Gemini OAuth file") }, + antigravityCredentialSource: antigravitySource + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports.count == 1) + #expect(result.reports[0].status == .healthy) + #expect(result.snapshot.limits.count == 1) + #expect(http.requests.count == 2) + #expect(http.requests.allSatisfy { $0.headers["Authorization"] == "Bearer antigravity-access" }) +} + +@Test func geminiConnectorPrefersAntigravityWhenGeminiMetadataIsUnavailable() async throws { + let localGeminiCredentials = #"{"refresh_token":"legacy-gemini-refresh"}"#.data(using: .utf8)! + let antigravityPayload = #"{"auth_method":"consumer","token":{"access_token":"antigravity-access","refresh_token":"antigravity-refresh","expiry":"2099-05-22T17:00:00.000000000Z"}}"# + let storedAntigravityPayload = "go-keyring-base64:\(Data(antigravityPayload.utf8).base64EncodedString())" + let load = #"{"cloudaicompanionProject":"project-secret"}"#.data(using: .utf8)! + let quota = #"{"buckets":[{"modelId":"gemini-3-flash-preview","remainingFraction":0.5}]}"#.data(using: .utf8)! + let http = StubHTTPClient(responses: [ + ConnectorHTTPResponse(statusCode: 200, data: load), + ConnectorHTTPResponse(statusCode: 200, data: quota), + ]) + let antigravitySource = AntigravityKeychainCredentialSource( + credentialLoader: InMemoryProviderCredentialStore(storage: [ + AntigravityKeychainCredentialSource.accountID: Data(storedAntigravityPayload.utf8), + ]) + ) + let connector = GeminiCodeAssistConnector( + accounts: [GeminiAccountConfiguration(authPath: "/tmp/gemini.json", accountName: "Google", clientID: "", clientSecret: "")], + httpClient: http, + fileLoader: { _ in localGeminiCredentials }, + antigravityCredentialSource: antigravitySource + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports[0].status == .healthy) + #expect(http.requests.count == 2) + #expect(http.requests.allSatisfy { $0.headers["Authorization"] == "Bearer antigravity-access" }) +} + +@Test func geminiConnectorReportsExpiredAntigravityTokenWithoutGeminiMetadata() async throws { + let antigravityPayload = #"{"auth_method":"consumer","token":{"access_token":"expired-access","refresh_token":"antigravity-refresh","expiry":"2000-05-22T17:00:00.000000000Z"}}"# + let storedAntigravityPayload = "go-keyring-base64:\(Data(antigravityPayload.utf8).base64EncodedString())" + let antigravitySource = AntigravityKeychainCredentialSource( + credentialLoader: InMemoryProviderCredentialStore(storage: [ + AntigravityKeychainCredentialSource.accountID: Data(storedAntigravityPayload.utf8), + ]) + ) + let connector = GeminiCodeAssistConnector( + accounts: [GeminiAccountConfiguration(authPath: "/tmp/missing-gemini.json", accountName: "Google", clientID: "", clientSecret: "")], + httpClient: StubHTTPClient(responses: []), + fileLoader: { _ in throw ConnectorError.invalidAuth("missing Gemini OAuth file") }, + antigravityCredentialSource: antigravitySource + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports.count == 1) + #expect(result.reports[0].status == .failure) + #expect(result.reports[0].errorMessage?.contains("Open Antigravity") == true) +} + @Test func claudeOAuthConnectorRefreshesUsageWindows() async throws { let credentials = #"{"accessToken":"access-secret","refreshToken":"refresh-secret","expiresAt":"2099-01-01T00:00:00Z","scopes":["user:profile","user:inference"]}"#.data(using: .utf8)! let usage = #""" diff --git a/docs/architecture.md b/docs/architecture.md index ac5106f..42d8ae7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,9 +59,10 @@ MVP connectors: - `CodexRateLimitConnector`: reads Codex-style auth roots such as `~/.code` or `~/.codex`, calls the live Codex usage endpoint, and normalizes primary, secondary, and additional percent-window buckets. -- `GeminiCodeAssistConnector`: reads Gemini CLI OAuth credentials, uses - explicitly supplied OAuth client inputs, resolves the active Code Assist - project internally, and normalizes model quota buckets as percent pressure. +- `GeminiCodeAssistConnector`: reads Google coding-tool credentials from + Antigravity Keychain sign-in or Gemini CLI OAuth credentials, resolves the + active Code Assist project internally, and normalizes model quota buckets as + percent pressure. - `ClaudeLocalStatusConnector`: runs `claude auth status --json` and summarizes `~/.claude/stats-cache.json`; live personal subscription allowance remains unknown unless a clean provider signal appears. @@ -113,9 +114,10 @@ intervals that cross resets. The MVP account configuration is also local JSON. It stores account labels, enabled/disabled state, connector kind, and local paths or command names needed -to locate provider CLI auth. It does not store provider secrets. Gemini OAuth -client inputs are referenced by environment variable names so the values can -remain outside the repository and outside the account config file. +to locate provider CLI auth. It does not store provider secrets. Google usage can +use Antigravity's Keychain token when present; Gemini OAuth client inputs are +referenced by environment variable names so the values can remain outside the +repository and outside the account config file. Widget interactions should keep the widget simple. Tapping the widget should open the app to the relevant provider or account detail; mutation and setup stay diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index 191fe7f..41da22d 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -154,19 +154,27 @@ Preferred v1 connector scope: `CodexRateLimitProbe` executable exists to prove the direct call path against an existing Codex `auth.json` while printing only redacted summaries. -### Gemini Code Assist Connector +### Google Antigravity / Gemini Code Assist Connector -The local Gemini CLI path gives Context Panel a second viable live connector. -The CLI stores OAuth credentials under `~/.gemini/oauth_creds.json`, while the -active account metadata lives separately under `~/.gemini/google_accounts.json`. -The quota values are not persisted as a durable local cache; Gemini CLI keeps -quota state in memory and refreshes it from the Code Assist backend. +The local Google coding-tool path gives Context Panel a second viable live +connector. Antigravity stores a Google access token in the macOS Keychain under +the generic-password service `gemini` and account `antigravity`. Legacy Gemini +CLI installs store OAuth credentials under `~/.gemini/oauth_creds.json`, while +the active account metadata lives separately under `~/.gemini/google_accounts.json`. +The quota values are not persisted as a durable local cache; Antigravity and +Gemini CLI refresh them from the Code Assist backend. Preferred v1 connector scope: -- Resolve `GEMINI_CLI_HOME`, then default to `~/.gemini`. +- Prefer a valid Antigravity Keychain access token when Gemini CLI OAuth client + metadata is unavailable, so Antigravity-only installs can refresh without a + separate `oauth_creds.json` file. +- Resolve `GEMINI_CLI_HOME`, then default to `~/.gemini`, for legacy Gemini CLI + auth and metadata fallback. - Read `oauth_creds.json` only to refresh an access token locally; never print, - store, or upload token values. + store, or upload token values. If Antigravity's Keychain token is expired and + no Gemini CLI OAuth client metadata is available, ask the user to open + Antigravity to refresh Google authentication. - Call the Gemini Code Assist load path to resolve the active project internally; never print or persist the raw project identifier. - Call the Gemini Code Assist quota path and normalize buckets by model ID, @@ -183,10 +191,14 @@ Preferred v1 connector scope: - Mark confidence as observed because this is a product backend surface rather than a public quota API contract. -The local `GeminiQuotaProbe` executable proves this path with redacted output. -On 2026-05-06 it returned seven live model buckets for the local Gemini CLI -account, including Gemini 2.5 and Gemini 3 preview models, with percent -remaining and reset timestamps. +The local `GeminiQuotaProbe` executable proves the legacy Gemini CLI path with +redacted output. On 2026-05-06 it returned seven live model buckets for the +local Gemini CLI account, including Gemini 2.5 and Gemini 3 preview models, with +percent remaining and reset timestamps. On 2026-05-22 the installed Context +Panel app was also validated against an Antigravity-only path by temporarily +pointing the Google account at a missing `oauth_creds.json`; the app still +refreshed Google limits through Antigravity's Keychain token, then the account +configuration was restored. Current public Google docs have a split contract. Gemini Apps help announced usage-limit changes starting 2026-05-17 and describes compute-based limits that