Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 10 additions & 4 deletions Sources/ContextPanelCore/AccountConfigurationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
147 changes: 144 additions & 3 deletions Sources/ContextPanelCore/GeminiCodeAssistQuota.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions Sources/ContextPanelCore/ProviderCredentialStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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)."
}
}
}
6 changes: 3 additions & 3 deletions Sources/ContextPanelPreview/ContextPanelPreviewApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading