From aed357eab3eee0c17004f0d967adf0f57e7aacba Mon Sep 17 00:00:00 2001 From: soup Date: Fri, 30 Jan 2026 20:56:54 +0100 Subject: [PATCH 1/5] fix: avoid claude keychain prompts --- CHANGELOG.md | 1 + Sources/CodexBar/KeychainMigration.swift | 114 +++++++++-- Sources/CodexBar/UsageStore+Refresh.swift | 12 ++ Sources/CodexBar/UsageStore.swift | 2 +- .../KeychainAccessPreflight.swift | 5 + .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 179 ++++++++++++++++-- .../Claude/ClaudeProviderDescriptor.swift | 4 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 8 +- .../ClaudeOAuthCredentialsStoreTests.swift | 47 +++++ .../KeychainMigrationTests.swift | 8 + 10 files changed, 343 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b06b30..fc3564c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Providers: add Dia browser support across cookie import and profile detection (#209). Thanks @validatedev! - Codex: include archived session logs in local token cost scanning and dedupe by session id. - Claude: harden CLI /usage parsing and avoid ANTHROPIC_* env interference during probes. +- Claude: avoid background Keychain prompts during OAuth refresh and refresh cached credentials after file/keychain changes. ### Menu & Menu Bar - Menu: opening OpenAI web submenus triggers a refresh when the data is stale. diff --git a/Sources/CodexBar/KeychainMigration.swift b/Sources/CodexBar/KeychainMigration.swift index 834f1c1e..8920180f 100644 --- a/Sources/CodexBar/KeychainMigration.swift +++ b/Sources/CodexBar/KeychainMigration.swift @@ -1,5 +1,6 @@ import CodexBarCore import Foundation +import LocalAuthentication import Security /// Migrates keychain items to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly @@ -7,6 +8,7 @@ import Security enum KeychainMigration { private static let log = CodexBarLog.logger(LogCategories.keychainMigration) private static let migrationKey = "KeychainMigrationV1Completed" + private static let claudeMigrationKey = "KeychainMigrationClaudeCredentialsV1" struct MigrationItem: Hashable, Sendable { let service: String @@ -32,39 +34,64 @@ enum KeychainMigration { MigrationItem(service: "Claude Code-credentials", account: nil), ] - /// Run migration once per installation + /// Run migration once per installation (with a Claude-specific follow-up when credentials change). static func migrateIfNeeded() { guard !KeychainAccessGate.isDisabled else { self.log.info("Keychain access disabled; skipping migration") return } - guard !UserDefaults.standard.bool(forKey: self.migrationKey) else { - self.log.debug("Keychain migration already completed, skipping") - return - } - self.log.info("Starting keychain migration to reduce permission prompts") + if !UserDefaults.standard.bool(forKey: self.migrationKey) { + self.log.info("Starting keychain migration to reduce permission prompts") - var migratedCount = 0 - var errorCount = 0 + var migratedCount = 0 + var errorCount = 0 - for item in self.itemsToMigrate { - do { - if try self.migrateItem(item) { - migratedCount += 1 + for item in self.itemsToMigrate { + do { + if try self.migrateItem(item) { + migratedCount += 1 + } + } catch { + errorCount += 1 + self.log.error("Failed to migrate \(item.label): \(String(describing: error))") } - } catch { - errorCount += 1 - self.log.error("Failed to migrate \(item.label): \(String(describing: error))") } + + self.log.info("Keychain migration complete: \(migratedCount) migrated, \(errorCount) errors") + UserDefaults.standard.set(true, forKey: self.migrationKey) + + if migratedCount > 0 { + self.log.info("✅ Future rebuilds will not prompt for keychain access") + } + } else { + self.log.debug("Keychain migration already completed, skipping") } - self.log.info("Keychain migration complete: \(migratedCount) migrated, \(errorCount) errors") - UserDefaults.standard.set(true, forKey: self.migrationKey) + self.migrateClaudeCredentialsIfNeeded() + } - if migratedCount > 0 { - self.log.info("✅ Future rebuilds will not prompt for keychain access") + static func migrateClaudeCredentialsIfNeeded() { + guard let item = self.itemsToMigrate.first(where: { $0.service == "Claude Code-credentials" }) else { + return } + guard let fingerprint = self.claudeCredentialsFingerprint() else { return } + + let stored = self.loadClaudeMigrationFingerprint() + if stored == fingerprint { return } + + do { + if try self.migrateItem(item) { + self.log.info("Migrated Claude credentials to ThisDeviceOnly") + } else { + self.log.debug("Claude credentials already using correct accessibility") + } + } catch { + self.log.error("Failed to migrate Claude credentials: \(String(describing: error))") + return + } + + self.saveClaudeMigrationFingerprint(fingerprint) } /// Migrate a single keychain item to the new accessibility level @@ -141,6 +168,55 @@ enum KeychainMigration { return true } + private struct ClaudeCredentialsFingerprint: Codable, Equatable { + let modifiedAt: Int? + let accessible: String? + } + + private static func claudeCredentialsFingerprint() -> ClaudeCredentialsFingerprint? { + #if os(macOS) + if KeychainAccessGate.isDisabled { return nil } + let context = LAContext() + context.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "Claude Code-credentials", + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecUseAuthenticationContext as String: context, + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let attrs = result as? [String: Any] else { + return nil + } + let modifiedAt = (attrs[kSecAttrModificationDate as String] as? Date) + .map { Int($0.timeIntervalSince1970) } + let accessible = attrs[kSecAttrAccessible as String] as? String + return ClaudeCredentialsFingerprint(modifiedAt: modifiedAt, accessible: accessible) + #else + return nil + #endif + } + + private static func loadClaudeMigrationFingerprint() -> ClaudeCredentialsFingerprint? { + guard let data = UserDefaults.standard.data(forKey: self.claudeMigrationKey) else { return nil } + return try? JSONDecoder().decode(ClaudeCredentialsFingerprint.self, from: data) + } + + private static func saveClaudeMigrationFingerprint(_ fingerprint: ClaudeCredentialsFingerprint) { + guard let data = try? JSONEncoder().encode(fingerprint) else { return } + UserDefaults.standard.set(data, forKey: self.claudeMigrationKey) + } + + #if DEBUG + static func _resetClaudeMigrationTrackingForTesting() { + UserDefaults.standard.removeObject(forKey: self.claudeMigrationKey) + UserDefaults.standard.removeObject(forKey: self.migrationKey) + } + #endif + /// Reset migration flag (for testing) static func resetMigrationFlag() { UserDefaults.standard.removeObject(forKey: self.migrationKey) diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 5449d67b..b98a1ff8 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -43,6 +43,18 @@ extension UsageStore { } let outcome = await spec.fetch() + if provider == .claude, ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() { + await MainActor.run { + self.snapshots.removeValue(forKey: .claude) + self.errors[.claude] = nil + self.lastSourceLabels.removeValue(forKey: .claude) + self.lastFetchAttempts.removeValue(forKey: .claude) + self.accountSnapshots.removeValue(forKey: .claude) + self.tokenSnapshots.removeValue(forKey: .claude) + self.tokenErrors[.claude] = nil + self.failureGates[.claude]?.reset() + } + } await MainActor.run { self.lastFetchAttempts[provider] = outcome.attempts } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 69491056..2bfa6cca 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1250,7 +1250,7 @@ extension UsageStore { } else { ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) { msg in lines.append(msg) } } - let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load()) != nil + let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load(allowKeychainPrompt: false)) != nil let strategy = ClaudeProviderDescriptor.resolveUsageStrategy( selectedDataSource: claudeUsageDataSource, diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 866eecb1..fc9a6cc6 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -75,6 +75,11 @@ public enum KeychainAccessPreflight { case errSecInteractionNotAllowed: self.log.info("Keychain preflight requires interaction", metadata: ["service": service]) return .interactionRequired + case errSecAuthFailed: + self.log.info( + "Keychain preflight requires authentication", + metadata: ["service": service, "status": "\(status)"]) + return .interactionRequired default: self.log.warning("Keychain preflight failed", metadata: ["service": service, "status": "\(status)"]) return .failure(Int(status)) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 8e8ab05b..5cd57e1c 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1,5 +1,6 @@ import Foundation #if os(macOS) +import LocalAuthentication import Security #endif @@ -111,6 +112,21 @@ public enum ClaudeOAuthCredentialsStore { public static let environmentTokenKey = "CODEXBAR_CLAUDE_OAUTH_TOKEN" public static let environmentScopesKey = "CODEXBAR_CLAUDE_OAUTH_SCOPES" + private static let log = CodexBarLog.logger(LogCategories.claudeUsage) + private static let fileFingerprintKey = "ClaudeOAuthCredentialsFileFingerprintV1" + + #if DEBUG + private nonisolated(unsafe) static var keychainAccessOverride: Bool? + static func setKeychainAccessOverrideForTesting(_ disabled: Bool?) { + self.keychainAccessOverride = disabled + } + #endif + + private struct CredentialsFileFingerprint: Codable, Equatable, Sendable { + let modifiedAt: Int? + let size: Int + } + struct CacheEntry: Codable, Sendable { let data: Data let storedAt: Date @@ -123,13 +139,16 @@ public enum ClaudeOAuthCredentialsStore { private static let memoryCacheValidityDuration: TimeInterval = 1800 public static func load( - environment: [String: String] = ProcessInfo.processInfo.environment) throws -> ClaudeOAuthCredentials + environment: [String: String] = ProcessInfo.processInfo.environment, + allowKeychainPrompt: Bool = true) throws -> ClaudeOAuthCredentials { if let credentials = self.loadFromEnvironment(environment) { return credentials } - // 1. Check in-memory cache first + _ = self.invalidateCacheIfCredentialsFileChanged() + _ = self.invalidateCacheIfClaudeKeychainChanged() + if let cached = self.cachedCredentials, let timestamp = self.cacheTimestamp, Date().timeIntervalSince(timestamp) < self.memoryCacheValidityDuration, @@ -183,16 +202,30 @@ public enum ClaudeOAuthCredentialsStore { lastError = error } - // 4. Fall back to Claude's keychain (may prompt user) - if let keychainData = try? self.loadFromClaudeKeychain() { - do { - let creds = try ClaudeOAuthCredentials.parse(data: keychainData) - self.cachedCredentials = creds - self.cacheTimestamp = Date() - self.saveToCacheKeychain(keychainData) - return creds - } catch { - lastError = error + // 4. Fall back to Claude's keychain (may prompt user if allowed) + if allowKeychainPrompt { + if let keychainData = try? self.loadFromClaudeKeychain() { + do { + let creds = try ClaudeOAuthCredentials.parse(data: keychainData) + self.cachedCredentials = creds + self.cacheTimestamp = Date() + self.saveToCacheKeychain(keychainData) + return creds + } catch { + lastError = error + } + } + } else { + if let keychainData = try? self.loadFromClaudeKeychainWithoutPrompt() { + do { + let creds = try ClaudeOAuthCredentials.parse(data: keychainData) + self.cachedCredentials = creds + self.cacheTimestamp = Date() + self.saveToCacheKeychain(keychainData) + return creds + } catch { + lastError = error + } } } @@ -204,7 +237,7 @@ public enum ClaudeOAuthCredentialsStore { } public static func loadFromFile() throws -> Data { - let url = self.credentialsURLOverride ?? Self.defaultCredentialsURL() + let url = self.credentialsFileURL() do { return try Data(contentsOf: url) } catch { @@ -215,6 +248,49 @@ public enum ClaudeOAuthCredentialsStore { } } + @discardableResult + public static func invalidateCacheIfCredentialsFileChanged() -> Bool { + let current = self.currentFileFingerprint() + let stored = self.loadFileFingerprint() + guard current != stored else { return false } + self.saveFileFingerprint(current) + self.log.info("Claude OAuth credentials file changed; invalidating cache") + self.invalidateCache() + return true + } + + @discardableResult + public static func invalidateCacheIfClaudeKeychainChanged() -> Bool { + #if os(macOS) + guard self.keychainAccessAllowed else { return false } + if case .interactionRequired = KeychainAccessPreflight + .checkGenericPassword(service: self.claudeKeychainService, account: nil) + { + return false + } + + guard case let .found(entry) = KeychainCacheStore.load(key: self.cacheKey, as: CacheEntry.self), + let cachedCreds = try? ClaudeOAuthCredentials.parse(data: entry.data) + else { + return false + } + + guard let keychainData = try? self.loadFromClaudeKeychainWithoutPrompt(), + let keychainCreds = try? ClaudeOAuthCredentials.parse(data: keychainData) + else { + return false + } + + guard cachedCreds.accessToken != keychainCreds.accessToken else { return false } + + self.log.info("Claude keychain credentials changed; invalidating cache") + self.invalidateCache() + return true + #else + return false + #endif + } + /// Invalidate the credentials cache (call after login/logout) public static func invalidateCache() { self.cachedCredentials = nil @@ -224,7 +300,7 @@ public enum ClaudeOAuthCredentialsStore { public static func loadFromClaudeKeychain() throws -> Data { #if os(macOS) - if KeychainAccessGate.isDisabled { + if !self.keychainAccessAllowed { throw ClaudeOAuthCredentialsError.notFound } if case .interactionRequired = KeychainAccessPreflight @@ -266,6 +342,39 @@ public enum ClaudeOAuthCredentialsStore { try self.loadFromClaudeKeychain() } + private static func loadFromClaudeKeychainWithoutPrompt() throws -> Data? { + #if os(macOS) + let context = LAContext() + context.interactionNotAllowed = true + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.claudeKeychainService, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + kSecUseAuthenticationContext as String: context, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + guard let data = result as? Data, !data.isEmpty else { + return nil + } + return data + case errSecItemNotFound: + return nil + case errSecInteractionNotAllowed: + return nil + default: + return nil + } + #else + return nil + #endif + } + private static func loadFromEnvironment(_ environment: [String: String]) -> ClaudeOAuthCredentials? { guard let token = environment[self.environmentTokenKey]?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty @@ -303,6 +412,48 @@ public enum ClaudeOAuthCredentialsStore { KeychainCacheStore.clear(key: self.cacheKey) } + private static var keychainAccessAllowed: Bool { + #if DEBUG + if let override = self.keychainAccessOverride { + return !override + } + #endif + return !KeychainAccessGate.isDisabled + } + + private static func credentialsFileURL() -> URL { + self.credentialsURLOverride ?? self.defaultCredentialsURL() + } + + private static func loadFileFingerprint() -> CredentialsFileFingerprint? { + guard let data = UserDefaults.standard.data(forKey: self.fileFingerprintKey) else { return nil } + return try? JSONDecoder().decode(CredentialsFileFingerprint.self, from: data) + } + + private static func saveFileFingerprint(_ fingerprint: CredentialsFileFingerprint?) { + guard let fingerprint else { + UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) + return + } + if let data = try? JSONEncoder().encode(fingerprint) { + UserDefaults.standard.set(data, forKey: self.fileFingerprintKey) + } + } + + private static func currentFileFingerprint() -> CredentialsFileFingerprint? { + let url = self.credentialsFileURL() + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil } + let size = (attrs[.size] as? NSNumber)?.intValue ?? 0 + let modifiedAt = (attrs[.modificationDate] as? Date).map { Int($0.timeIntervalSince1970) } + return CredentialsFileFingerprint(modifiedAt: modifiedAt, size: size) + } + + #if DEBUG + static func _resetCredentialsFileTrackingForTesting() { + UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) + } + #endif + private static func defaultCredentialsURL() -> URL { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(self.credentialsPath) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index e6486cff..585e8e7b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -136,7 +136,9 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .oauth func isAvailable(_ context: ProviderFetchContext) async -> Bool { - guard let creds = try? ClaudeOAuthCredentialsStore.load(environment: context.env) else { return false } + guard let creds = try? ClaudeOAuthCredentialsStore.load( + environment: context.env, + allowKeychainPrompt: false) else { return false } // In Auto mode, only prefer OAuth when we know the scope is present. // In OAuth-only mode, still show a useful error message even when the scope is missing. // (The strategy can fall back to Web/CLI when allowed by the fetch plan.) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 9d935fb8..b77cd024 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -221,7 +221,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { public func loadLatestUsage(model: String = "sonnet") async throws -> ClaudeUsageSnapshot { switch self.dataSource { case .auto: - let oauthCreds = try? ClaudeOAuthCredentialsStore.load(environment: self.environment) + let oauthCreds = try? ClaudeOAuthCredentialsStore.load( + environment: self.environment, + allowKeychainPrompt: false) let hasOAuthCredentials = oauthCreds?.scopes.contains("user:profile") ?? false let hasWebSession = if let header = self.manualCookieHeader { ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header) @@ -262,7 +264,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private func loadViaOAuth() async throws -> ClaudeUsageSnapshot { do { - let creds = try ClaudeOAuthCredentialsStore.load(environment: self.environment) + let creds = try ClaudeOAuthCredentialsStore.load( + environment: self.environment, + allowKeychainPrompt: false) if creds.isExpired { throw ClaudeUsageError.oauthFailed("Claude OAuth token expired. Run `claude` to refresh.") } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 62a2387f..282b5dfc 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -27,6 +27,9 @@ struct ClaudeOAuthCredentialsStoreTests { KeychainAccessGate.isDisabled = true defer { KeychainAccessGate.isDisabled = previousGate } + ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) + defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } + let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -47,12 +50,53 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore.invalidateCache() KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) defer { KeychainCacheStore.clear(key: cacheKey) } + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + _ = try ClaudeOAuthCredentialsStore.load(environment: [:]) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) #expect(creds.accessToken == "cached") #expect(creds.isExpired == false) } + @Test + func invalidatesCacheWhenCredentialsFileChanges() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(fileURL) + defer { ClaudeOAuthCredentialsStore.setCredentialsURLOverrideForTesting(nil) } + + let first = self.makeCredentialsData( + accessToken: "first", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try first.write(to: fileURL) + + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date()) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + + _ = try ClaudeOAuthCredentialsStore.load(environment: [:]) + + let updated = self.makeCredentialsData( + accessToken: "second", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try updated.write(to: fileURL) + + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + KeychainCacheStore.clear(key: cacheKey) + + let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) + #expect(creds.accessToken == "second") + } + @Test func returnsExpiredFileWhenNoOtherSources() throws { KeychainCacheStore.setTestStoreForTesting(true) @@ -70,6 +114,9 @@ struct ClaudeOAuthCredentialsStoreTests { expiresAt: Date(timeIntervalSinceNow: -3600)) try expiredData.write(to: fileURL) + ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(true) + defer { ClaudeOAuthCredentialsStore.setKeychainAccessOverrideForTesting(nil) } + let previousGate = KeychainAccessGate.isDisabled KeychainAccessGate.isDisabled = true defer { KeychainAccessGate.isDisabled = previousGate } diff --git a/Tests/CodexBarTests/KeychainMigrationTests.swift b/Tests/CodexBarTests/KeychainMigrationTests.swift index 7627985a..9b350d5b 100644 --- a/Tests/CodexBarTests/KeychainMigrationTests.swift +++ b/Tests/CodexBarTests/KeychainMigrationTests.swift @@ -12,13 +12,21 @@ struct KeychainMigrationTests { "com.steipete.CodexBar:cursor-cookie", "com.steipete.CodexBar:factory-cookie", "com.steipete.CodexBar:minimax-cookie", + "com.steipete.CodexBar:minimax-api-token", "com.steipete.CodexBar:augment-cookie", "com.steipete.CodexBar:copilot-api-token", "com.steipete.CodexBar:zai-api-token", + "com.steipete.CodexBar:synthetic-api-key", "Claude Code-credentials:", ] let missing = expected.subtracting(items) #expect(missing.isEmpty, "Missing migration entries: \(missing.sorted())") } + + @Test + func claudeMigrationTrackingResets() { + KeychainMigration._resetClaudeMigrationTrackingForTesting() + #expect(true) + } } From cf46ddb2001a9fea20ac2f9489bb5d2753b4cca6 Mon Sep 17 00:00:00 2001 From: soup Date: Fri, 30 Jan 2026 21:13:54 +0100 Subject: [PATCH 2/5] fix: skip codex keychain prompts --- CHANGELOG.md | 2 +- Sources/CodexBar/UsageStore.swift | 43 ++++++++++++++----- .../BrowserCookieImportOrder.swift | 10 ++++- ...OpenAIDashboardBrowserCookieImporter.swift | 8 +++- .../CodexBarTests/BrowserDetectionTests.swift | 11 +++++ 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3564c9..bf8f266e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Providers: add Dia browser support across cookie import and profile detection (#209). Thanks @validatedev! - Codex: include archived session logs in local token cost scanning and dedupe by session id. - Claude: harden CLI /usage parsing and avoid ANTHROPIC_* env interference during probes. -- Claude: avoid background Keychain prompts during OAuth refresh and refresh cached credentials after file/keychain changes. +- Claude/Codex: avoid background Keychain prompts during OAuth refresh and OpenAI web cookie imports; refresh cached credentials after file/keychain changes. ### Menu & Menu Bar - Menu: opening OpenAI web submenus triggers a refresh when the data is stale. diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 2bfa6cca..67ca9bb6 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -440,7 +440,9 @@ final class UsageStore { // OpenAI web scrape depends on the current Codex account email (which can change after login/account switch). // Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. - await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage) + await self.refreshOpenAIDashboardIfNeeded( + force: forceTokenUsage, + allowKeychainPrompt: forceTokenUsage) if self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) @@ -663,7 +665,7 @@ extension UsageStore { if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval { return } let stamp = now.formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)") - Task { await self.refreshOpenAIDashboardIfNeeded(force: true) } + Task { await self.refreshOpenAIDashboardIfNeeded(force: true, allowKeychainPrompt: false) } } private func applyOpenAIDashboard(_ dash: OpenAIDashboardSnapshot, targetEmail: String?) async { @@ -708,7 +710,10 @@ extension UsageStore { } } - private func refreshOpenAIDashboardIfNeeded(force: Bool = false) async { + private func refreshOpenAIDashboardIfNeeded( + force: Bool = false, + allowKeychainPrompt: Bool = false) async + { guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { self.resetOpenAIWebState() return @@ -754,7 +759,8 @@ extension UsageStore { // user. if let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: targetEmail, - force: true) + force: true, + allowKeychainPrompt: allowKeychainPrompt) { effectiveEmail = imported } @@ -769,7 +775,8 @@ extension UsageStore { if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) { if let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: targetEmail, - force: true) + force: true, + allowKeychainPrompt: allowKeychainPrompt) { effectiveEmail = imported } @@ -798,7 +805,11 @@ extension UsageStore { // importing cookies from the user's browser. let targetEmail = self.codexAccountEmailForOpenAIDashboard() var effectiveEmail = targetEmail - if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) { + if let imported = await self.importOpenAIDashboardCookiesIfNeeded( + targetEmail: targetEmail, + force: true, + allowKeychainPrompt: allowKeychainPrompt) + { effectiveEmail = imported } do { @@ -821,7 +832,11 @@ extension UsageStore { } catch OpenAIDashboardFetcher.FetchError.loginRequired { let targetEmail = self.codexAccountEmailForOpenAIDashboard() var effectiveEmail = targetEmail - if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) { + if let imported = await self.importOpenAIDashboardCookiesIfNeeded( + targetEmail: targetEmail, + force: true, + allowKeychainPrompt: allowKeychainPrompt) + { effectiveEmail = imported } do { @@ -884,11 +899,18 @@ extension UsageStore { func importOpenAIDashboardBrowserCookiesNow() async { self.resetOpenAIWebDebugLog(context: "manual import") let targetEmail = self.codexAccountEmailForOpenAIDashboard() - _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) - await self.refreshOpenAIDashboardIfNeeded(force: true) + _ = await self.importOpenAIDashboardCookiesIfNeeded( + targetEmail: targetEmail, + force: true, + allowKeychainPrompt: true) + await self.refreshOpenAIDashboardIfNeeded(force: true, allowKeychainPrompt: true) } - private func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? { + private func importOpenAIDashboardCookiesIfNeeded( + targetEmail: String?, + force: Bool, + allowKeychainPrompt: Bool) async -> String? + { let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true let cookieSource = self.settings.codexCookieSource @@ -942,6 +964,7 @@ extension UsageStore { result = try await importer.importBestCookies( intoAccountEmail: normalizedTarget, allowAnyAccount: allowAnyAccount, + allowKeychainPrompt: allowKeychainPrompt, logger: log) case .off: result = OpenAIDashboardBrowserCookieImporter.ImportResult( diff --git a/Sources/CodexBarCore/BrowserCookieImportOrder.swift b/Sources/CodexBarCore/BrowserCookieImportOrder.swift index b2a8f93c..58d296cf 100644 --- a/Sources/CodexBarCore/BrowserCookieImportOrder.swift +++ b/Sources/CodexBarCore/BrowserCookieImportOrder.swift @@ -14,10 +14,16 @@ extension [Browser] { /// Filters a browser list to sources worth attempting for cookie imports. /// /// This is intentionally stricter than "app installed": it aims to avoid unnecessary Keychain prompts. - public func cookieImportCandidates(using detection: BrowserDetection) -> [Browser] { + public func cookieImportCandidates( + using detection: BrowserDetection, + allowKeychainPrompt: Bool = true) -> [Browser] + { guard !KeychainAccessGate.isDisabled else { return [] } let candidates = self.filter { detection.isCookieSourceAvailable($0) } - return candidates.filter { BrowserCookieAccessGate.shouldAttempt($0) } + let keychainFiltered = allowKeychainPrompt + ? candidates + : candidates.filter { !$0.usesKeychainForCookieDecryption } + return keychainFiltered.filter { BrowserCookieAccessGate.shouldAttempt($0) } } /// Filters a browser list to sources with usable profile data on disk. diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 8f211c5c..98a35aa6 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -94,6 +94,7 @@ public struct OpenAIDashboardBrowserCookieImporter { public func importBestCookies( intoAccountEmail targetEmail: String?, allowAnyAccount: Bool = false, + allowKeychainPrompt: Bool = true, logger: ((String) -> Void)? = nil) async throws -> ImportResult { let log: (String) -> Void = { message in @@ -137,7 +138,12 @@ public struct OpenAIDashboardBrowserCookieImporter { } // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts - let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates(using: self.browserDetection) + if !allowKeychainPrompt { + log("Keychain prompts disabled; skipping keychain-backed browsers.") + } + let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates( + using: self.browserDetection, + allowKeychainPrompt: allowKeychainPrompt) for browserSource in installedBrowsers { if let match = await self.trySource( browserSource, diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index ff4c700a..70927e41 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -20,6 +20,17 @@ struct BrowserDetectionTests { #expect(browsers.cookieImportCandidates(using: detection).contains(.safari)) } + @Test + func filterSkipsKeychainBrowsersWhenPromptsDisabled() { + let detection = BrowserDetection( + homeDirectory: "/", + cacheTTL: 0, + fileExists: { _ in true }, + directoryContents: { _ in ["Default"] }) + let browsers: [Browser] = [.safari, .chrome, .firefox] + #expect(browsers.cookieImportCandidates(using: detection, allowKeychainPrompt: false) == [.safari, .firefox]) + } + @Test func filterPreservesOrder() { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) From 0757b8e167a7543140e1ff0e08b7571a231cb22b Mon Sep 17 00:00:00 2001 From: soup Date: Fri, 30 Jan 2026 22:22:52 +0100 Subject: [PATCH 3/5] fix: avoid keychain cache prompts --- CHANGELOG.md | 2 +- .../CodexBarCore/BrowserCookieImportOrder.swift | 4 +++- Sources/CodexBarCore/KeychainCacheStore.swift | 14 ++++++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf8f266e..22c7b6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Providers: add Dia browser support across cookie import and profile detection (#209). Thanks @validatedev! - Codex: include archived session logs in local token cost scanning and dedupe by session id. - Claude: harden CLI /usage parsing and avoid ANTHROPIC_* env interference during probes. -- Claude/Codex: avoid background Keychain prompts during OAuth refresh and OpenAI web cookie imports; refresh cached credentials after file/keychain changes. +- Claude/Codex: avoid background Keychain prompts during OAuth refresh, OpenAI web cookie imports, and keychain cache reads; refresh cached credentials after file/keychain changes. ### Menu & Menu Bar - Menu: opening OpenAI web submenus triggers a refresh when the data is stale. diff --git a/Sources/CodexBarCore/BrowserCookieImportOrder.swift b/Sources/CodexBarCore/BrowserCookieImportOrder.swift index 58d296cf..414593de 100644 --- a/Sources/CodexBarCore/BrowserCookieImportOrder.swift +++ b/Sources/CodexBarCore/BrowserCookieImportOrder.swift @@ -55,6 +55,8 @@ extension Browser { } #else extension Browser { - var usesKeychainForCookieDecryption: Bool { false } + var usesKeychainForCookieDecryption: Bool { + false + } } #endif diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index a361c90e..c20c5bf6 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -1,5 +1,6 @@ import Foundation #if os(macOS) +import LocalAuthentication import Security #endif @@ -34,19 +35,25 @@ public enum KeychainCacheStore { public static func load( key: Key, - as type: Entry.Type = Entry.self) -> LoadResult + as type: Entry.Type = Entry.self, + allowKeychainPrompt: Bool = false) -> LoadResult { if let testResult = loadFromTestStore(key: key, as: type) { return testResult } #if os(macOS) - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] + if !allowKeychainPrompt { + let context = LAContext() + context.interactionNotAllowed = true + query[kSecUseAuthenticationContext as String] = context + } var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -64,6 +71,9 @@ public enum KeychainCacheStore { return .found(decoded) case errSecItemNotFound: return .missing + case errSecInteractionNotAllowed, errSecAuthFailed: + self.log.debug("Keychain cache requires interaction; skipping", metadata: ["account": key.account]) + return .missing default: self.log.error("Keychain cache read failed (\(key.account)): \(status)") return .invalid From fe512533d0d9e072a6cc2dad6e813f0982488c56 Mon Sep 17 00:00:00 2001 From: soup Date: Fri, 30 Jan 2026 22:36:46 +0100 Subject: [PATCH 4/5] fix: skip keychain cache writes --- CHANGELOG.md | 2 +- Sources/CodexBarCore/KeychainCacheStore.swift | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22c7b6f7..e2881d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Providers: add Dia browser support across cookie import and profile detection (#209). Thanks @validatedev! - Codex: include archived session logs in local token cost scanning and dedupe by session id. - Claude: harden CLI /usage parsing and avoid ANTHROPIC_* env interference during probes. -- Claude/Codex: avoid background Keychain prompts during OAuth refresh, OpenAI web cookie imports, and keychain cache reads; refresh cached credentials after file/keychain changes. +- Claude/Codex: avoid background Keychain prompts during OAuth refresh, OpenAI web cookie imports, and keychain cache reads/writes; refresh cached credentials after file/keychain changes. ### Menu & Menu Bar - Menu: opening OpenAI web submenus triggers a refresh when the data is stale. diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index c20c5bf6..e7c770f5 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -94,10 +94,13 @@ public enum KeychainCacheStore { return } + let context = LAContext() + context.interactionNotAllowed = true let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, + kSecUseAuthenticationContext as String: context, ] let updateAttrs: [String: Any] = [ kSecValueData as String: data, @@ -107,6 +110,10 @@ public enum KeychainCacheStore { if updateStatus == errSecSuccess { return } + if updateStatus == errSecInteractionNotAllowed || updateStatus == errSecAuthFailed { + self.log.debug("Keychain cache update requires interaction; skipping", metadata: ["account": key.account]) + return + } if updateStatus != errSecItemNotFound { self.log.error("Keychain cache update failed (\(key.account)): \(updateStatus)") return @@ -118,6 +125,10 @@ public enum KeychainCacheStore { addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus == errSecInteractionNotAllowed || addStatus == errSecAuthFailed { + self.log.debug("Keychain cache add requires interaction; skipping", metadata: ["account": key.account]) + return + } if addStatus != errSecSuccess { self.log.error("Keychain cache add failed (\(key.account)): \(addStatus)") } From 3665df82ed5fecd13612b21c1ab69ce97ca808af Mon Sep 17 00:00:00 2001 From: soup Date: Fri, 30 Jan 2026 23:20:12 +0100 Subject: [PATCH 5/5] fix: allow oauth prompts on demand --- .../CodexBar/PreferencesProvidersPane.swift | 10 ++++----- Sources/CodexBar/ProviderRegistry.swift | 5 +++-- Sources/CodexBar/UsageStore+Refresh.swift | 8 +++++-- Sources/CodexBar/UsageStore.swift | 4 ++-- Sources/CodexBarCLI/CLIUsageCommand.swift | 1 + .../Claude/ClaudeProviderDescriptor.swift | 22 +++++++++++-------- .../Providers/Claude/ClaudeUsageFetcher.swift | 9 +++++--- .../Providers/ProviderFetchPlan.swift | 3 +++ 8 files changed, 39 insertions(+), 23 deletions(-) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index d4b29d86..c7bd6973 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -42,7 +42,7 @@ struct ProvidersPane: View { onCopyError: { text in self.copyToPasteboard(text) }, onRefresh: { Task { @MainActor in - await self.store.refreshProvider(provider, allowDisabled: true) + await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true) } }) } else { @@ -184,19 +184,19 @@ struct ProvidersPane: View { setActiveIndex: { index in self.settings.setActiveTokenAccountIndex(index, for: provider) Task { @MainActor in - await self.store.refreshProvider(provider, allowDisabled: true) + await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true) } }, addAccount: { label, token in self.settings.addTokenAccount(provider: provider, label: label, token: token) Task { @MainActor in - await self.store.refreshProvider(provider, allowDisabled: true) + await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true) } }, removeAccount: { accountID in self.settings.removeTokenAccount(provider: provider, accountID: accountID) Task { @MainActor in - await self.store.refreshProvider(provider, allowDisabled: true) + await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true) } }, openConfigFile: { @@ -205,7 +205,7 @@ struct ProvidersPane: View { reloadFromDisk: { self.settings.reloadTokenAccounts() Task { @MainActor in - await self.store.refreshProvider(provider, allowDisabled: true) + await self.store.refreshProvider(provider, allowDisabled: true, allowKeychainPrompt: true) } }) } diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 465020b0..41ac3bee 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -4,7 +4,7 @@ import Foundation struct ProviderSpec { let style: IconStyle let isEnabled: @MainActor () -> Bool - let fetch: () async -> ProviderFetchOutcome + let fetch: (_ allowKeychainPrompt: Bool) async -> ProviderFetchOutcome } struct ProviderRegistry { @@ -33,7 +33,7 @@ struct ProviderRegistry { let spec = ProviderSpec( style: descriptor.branding.iconStyle, isEnabled: { settings.isProviderEnabled(provider: provider, metadata: meta) }, - fetch: { + fetch: { allowKeychainPrompt in let sourceMode = ProviderCatalog.implementation(for: provider)? .sourceMode(context: ProviderSourceModeContext(provider: provider, settings: settings)) ?? .auto @@ -51,6 +51,7 @@ struct ProviderRegistry { let context = ProviderFetchContext( runtime: .app, sourceMode: sourceMode, + allowKeychainPrompt: allowKeychainPrompt, includeCredits: false, webTimeout: 60, webDebugDumpHTML: false, diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index b98a1ff8..c5f2c2a2 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -7,7 +7,11 @@ extension UsageStore { await self.performRuntimeAction(.forceSessionRefresh, for: .augment) } - func refreshProvider(_ provider: UsageProvider, allowDisabled: Bool = false) async { + func refreshProvider( + _ provider: UsageProvider, + allowDisabled: Bool = false, + allowKeychainPrompt: Bool = false) async + { guard let spec = self.providerSpecs[provider] else { return } if !spec.isEnabled(), !allowDisabled { @@ -42,7 +46,7 @@ extension UsageStore { } } - let outcome = await spec.fetch() + let outcome = await spec.fetch(allowKeychainPrompt) if provider == .claude, ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() { await MainActor.run { self.snapshots.removeValue(forKey: .claude) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 67ca9bb6..51f6d4e0 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -429,7 +429,7 @@ final class UsageStore { await withTaskGroup(of: Void.self) { group in for provider in UsageProvider.allCases { - group.addTask { await self.refreshProvider(provider) } + group.addTask { await self.refreshProvider(provider, allowKeychainPrompt: forceTokenUsage) } group.addTask { await self.refreshStatus(provider) } } group.addTask { await self.refreshCreditsIfNeeded() } @@ -445,7 +445,7 @@ final class UsageStore { allowKeychainPrompt: forceTokenUsage) if self.openAIDashboardRequiresLogin { - await self.refreshProvider(.codex) + await self.refreshProvider(.codex, allowKeychainPrompt: false) await self.refreshCreditsIfNeeded() } diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index d0ab5dfe..44484e93 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -256,6 +256,7 @@ extension CodexBarCLI { let fetchContext = ProviderFetchContext( runtime: .cli, sourceMode: effectiveSourceMode, + allowKeychainPrompt: true, includeCredits: command.includeCredits, webTimeout: command.webTimeout, webDebugDumpHTML: command.webDebugDumpHTML, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 585e8e7b..ba7ef026 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -136,16 +136,19 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .oauth func isAvailable(_ context: ProviderFetchContext) async -> Bool { - guard let creds = try? ClaudeOAuthCredentialsStore.load( + if let creds = try? ClaudeOAuthCredentialsStore.load( environment: context.env, - allowKeychainPrompt: false) else { return false } - // In Auto mode, only prefer OAuth when we know the scope is present. - // In OAuth-only mode, still show a useful error message even when the scope is missing. - // (The strategy can fall back to Web/CLI when allowed by the fetch plan.) - if context.sourceMode == .auto { - return creds.scopes.contains("user:profile") + allowKeychainPrompt: false) + { + // In Auto mode, only prefer OAuth when we know the scope is present. + // In OAuth-only mode, still show a useful error message even when the scope is missing. + // (The strategy can fall back to Web/CLI when allowed by the fetch plan.) + if context.sourceMode == .auto { + return creds.scopes.contains("user:profile") + } + return true } - return true + return context.allowKeychainPrompt && (context.sourceMode == .auto || context.sourceMode == .oauth) } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { @@ -153,7 +156,8 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { browserDetection: context.browserDetection, environment: context.env, dataSource: .oauth, - useWebExtras: false) + useWebExtras: false, + allowKeychainPrompt: context.allowKeychainPrompt) let usage = try await fetcher.loadLatestUsage(model: "sonnet") return self.makeResult( usage: Self.snapshot(from: usage), diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index b77cd024..dc155323 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -63,6 +63,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private let useWebExtras: Bool private let manualCookieHeader: String? private let keepCLISessionsAlive: Bool + private let allowKeychainPrompt: Bool private let browserDetection: BrowserDetection private static let log = CodexBarLog.logger(LogCategories.claudeUsage) @@ -77,7 +78,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { dataSource: ClaudeUsageDataSource = .oauth, useWebExtras: Bool = false, manualCookieHeader: String? = nil, - keepCLISessionsAlive: Bool = false) + keepCLISessionsAlive: Bool = false, + allowKeychainPrompt: Bool = false) { self.browserDetection = browserDetection self.environment = environment @@ -85,6 +87,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { self.useWebExtras = useWebExtras self.manualCookieHeader = manualCookieHeader self.keepCLISessionsAlive = keepCLISessionsAlive + self.allowKeychainPrompt = allowKeychainPrompt } // MARK: - Parsing helpers @@ -223,7 +226,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .auto: let oauthCreds = try? ClaudeOAuthCredentialsStore.load( environment: self.environment, - allowKeychainPrompt: false) + allowKeychainPrompt: self.allowKeychainPrompt) let hasOAuthCredentials = oauthCreds?.scopes.contains("user:profile") ?? false let hasWebSession = if let header = self.manualCookieHeader { ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header) @@ -266,7 +269,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { do { let creds = try ClaudeOAuthCredentialsStore.load( environment: self.environment, - allowKeychainPrompt: false) + allowKeychainPrompt: self.allowKeychainPrompt) if creds.isExpired { throw ClaudeUsageError.oauthFailed("Claude OAuth token expired. Run `claude` to refresh.") } diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index cadbdc81..3f044102 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -20,6 +20,7 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { public struct ProviderFetchContext: Sendable { public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode + public let allowKeychainPrompt: Bool public let includeCredits: Bool public let webTimeout: TimeInterval public let webDebugDumpHTML: Bool @@ -33,6 +34,7 @@ public struct ProviderFetchContext: Sendable { public init( runtime: ProviderRuntime, sourceMode: ProviderSourceMode, + allowKeychainPrompt: Bool = false, includeCredits: Bool, webTimeout: TimeInterval, webDebugDumpHTML: Bool, @@ -45,6 +47,7 @@ public struct ProviderFetchContext: Sendable { { self.runtime = runtime self.sourceMode = sourceMode + self.allowKeychainPrompt = allowKeychainPrompt self.includeCredits = includeCredits self.webTimeout = webTimeout self.webDebugDumpHTML = webDebugDumpHTML