diff --git a/CHANGELOG.md b/CHANGELOG.md index 18bb6447..0b793747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Add `allowKeychainPrompt` parameter to skip prompts during background refreshes and availability checks. - Add silent keychain access using `LAContext.interactionNotAllowed` to avoid unexpected prompts. - Cache credentials in CodexBar's own keychain after first successful access, avoiding repeated prompts to Claude Code's keychain. - - Re-migrate Claude credentials when they change to maintain proper accessibility settings. + - Avoid mutating Claude Code's keychain item during startup migration. ## 0.18.0-beta.2 — 2026-01-21 ### Highlights diff --git a/Sources/CodexBar/KeychainMigration.swift b/Sources/CodexBar/KeychainMigration.swift index e46d0167..edb9fc1b 100644 --- a/Sources/CodexBar/KeychainMigration.swift +++ b/Sources/CodexBar/KeychainMigration.swift @@ -2,15 +2,11 @@ import CodexBarCore import Foundation import Security -import LocalAuthentication - /// Migrates keychain items to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly /// to prevent permission prompts on every rebuild during development. 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 @@ -33,10 +29,9 @@ enum KeychainMigration { MigrationItem(service: "com.steipete.CodexBar", account: "copilot-api-token"), MigrationItem(service: "com.steipete.CodexBar", account: "zai-api-token"), MigrationItem(service: "com.steipete.CodexBar", account: "synthetic-api-key"), - MigrationItem(service: "Claude Code-credentials", account: nil), ] - /// Run migration once per installation (with a Claude-specific follow-up when credentials change). + /// Run migration once per installation static func migrateIfNeeded() { guard !KeychainAccessGate.isDisabled else { self.log.info("Keychain access disabled; skipping migration") @@ -69,31 +64,6 @@ enum KeychainMigration { } else { self.log.debug("Keychain migration already completed, skipping") } - - self.migrateClaudeCredentialsIfNeeded() - } - - 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 @@ -170,55 +140,6 @@ 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 d1cefc8e..0963b8a6 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -44,7 +44,7 @@ extension UsageStore { let outcome = await spec.fetch() if provider == .claude, - ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() { await MainActor.run { self.snapshots.removeValue(forKey: .claude) @@ -55,6 +55,7 @@ extension UsageStore { self.tokenSnapshots.removeValue(forKey: .claude) self.tokenErrors[.claude] = nil self.failureGates[.claude]?.reset() + self.tokenFailureGates[.claude]?.reset() self.lastTokenFetchAt.removeValue(forKey: .claude) } } @@ -63,7 +64,7 @@ extension UsageStore { } switch outcome.result { - case .success(let result): + case let .success(result): let scoped = result.usage.scoped(to: provider) await MainActor.run { self.handleSessionQuotaTransition(provider: provider, snapshot: scoped) @@ -77,12 +78,12 @@ extension UsageStore { provider: provider, settings: self.settings, store: self) runtime.providerDidRefresh(context: context, provider: provider) } - case .failure(let error): + case let .failure(error): await MainActor.run { let hadPriorData = self.snapshots[provider] != nil let shouldSurface = self.failureGates[provider]? - .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { self.errors[provider] = error.localizedDescription self.snapshots.removeValue(forKey: provider) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e4e1c850..1806f78b 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1252,6 +1252,9 @@ extension UsageStore { } // Don't prompt for keychain access during debug dump let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load(allowKeychainPrompt: false)) != nil + let hasClaudeBinary = BinaryLocator.resolveClaudeBinary( + env: ProcessInfo.processInfo.environment, + loginPATH: LoginShellPathCache.shared.current) != nil let strategy = ClaudeProviderDescriptor.resolveUsageStrategy( selectedDataSource: claudeUsageDataSource, @@ -1259,9 +1262,15 @@ extension UsageStore { hasWebSession: hasKey, hasOAuthCredentials: hasOAuthCredentials) - lines.append("strategy=\(strategy.dataSource.rawValue)") + if claudeUsageDataSource == .auto { + lines.append("pipeline_order=web→cli→oauth") + lines.append("auto_heuristic=\(strategy.dataSource.rawValue)") + } else { + lines.append("strategy=\(strategy.dataSource.rawValue)") + } lines.append("hasSessionKey=\(hasKey)") lines.append("hasOAuthCredentials=\(hasOAuthCredentials)") + lines.append("hasClaudeBinary=\(hasClaudeBinary)") if strategy.useWebExtras { lines.append("web_extras=enabled") } diff --git a/Sources/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index 5d1731e2..db0de16b 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -66,6 +66,14 @@ public enum BrowserCookieAccessGate { ]) } + public static func resetForTesting() { + self.lock.withLock { state in + state.loaded = true + state.deniedUntilByBrowser.removeAll() + UserDefaults.standard.removeObject(forKey: self.defaultsKey) + } + } + private static func chromiumKeychainRequiresInteraction() -> Bool { for label in self.safeStorageLabels { switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) { @@ -104,5 +112,6 @@ public enum BrowserCookieAccessGate { public static func recordIfNeeded(_ error: Error, now: Date = Date()) {} public static func recordDenied(for browser: Browser, now: Date = Date()) {} + public static func resetForTesting() {} } #endif diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 866eecb1..b06f225b 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -50,15 +50,13 @@ public enum KeychainAccessPreflight { public static func checkGenericPassword(service: String, account: String?) -> Outcome { #if os(macOS) guard !KeychainAccessGate.isDisabled else { return .notFound } - let context = LAContext() - context.interactionNotAllowed = true var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, - kSecUseAuthenticationContext as String: context, ] + KeychainNoUIQuery.apply(to: &query) if let account { query[kSecAttrAccount as String] = account } diff --git a/Sources/CodexBarCore/KeychainNoUIQuery.swift b/Sources/CodexBarCore/KeychainNoUIQuery.swift new file mode 100644 index 00000000..29ef456a --- /dev/null +++ b/Sources/CodexBarCore/KeychainNoUIQuery.swift @@ -0,0 +1,19 @@ +import Foundation + +#if os(macOS) +import LocalAuthentication +import Security + +enum KeychainNoUIQuery { + static func apply(to query: inout [String: Any]) { + let context = LAContext() + context.interactionNotAllowed = true + query[kSecUseAuthenticationContext as String] = context + + // NOTE: While Apple recommends using LAContext.interactionNotAllowed, that alone is not sufficient to + // prevent the legacy keychain "Allow/Deny" prompt on some configurations. We also set the UI policy to fail + // so SecItemCopyMatching returns errSecInteractionNotAllowed instead of showing UI. + query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 549062ee..eb72fa42 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1,8 +1,12 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + #if os(macOS) - import LocalAuthentication - import Security +import LocalAuthentication +import Security #endif public struct ClaudeOAuthCredentials: Sendable { @@ -17,8 +21,8 @@ public struct ClaudeOAuthCredentials: Sendable { refreshToken: String?, expiresAt: Date?, scopes: [String], - rateLimitTier: String? - ) { + rateLimitTier: String?) + { self.accessToken = accessToken self.refreshToken = refreshToken self.expiresAt = expiresAt @@ -93,21 +97,31 @@ public enum ClaudeOAuthCredentialsError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .decodeFailed: - "Claude OAuth credentials are invalid." + return "Claude OAuth credentials are invalid." case .missingOAuth: - "Claude OAuth credentials missing. Run `claude` to authenticate." + return "Claude OAuth credentials missing. Run `claude` to authenticate." case .missingAccessToken: - "Claude OAuth access token missing. Run `claude` to authenticate." + return "Claude OAuth access token missing. Run `claude` to authenticate." case .notFound: - "Claude OAuth credentials not found. Run `claude` to authenticate." - case .keychainError(let status): - "Claude OAuth keychain error: \(status)" - case .readFailed(let message): - "Claude OAuth credentials read failed: \(message)" - case .refreshFailed(let message): - "Claude OAuth token refresh failed: \(message). Run `claude` to re-authenticate." + return "Claude OAuth credentials not found. Run `claude` to authenticate." + case let .keychainError(status): + #if os(macOS) + if status == Int(errSecUserCanceled) + || status == Int(errSecAuthFailed) + || status == Int(errSecInteractionNotAllowed) + || status == Int(errSecNoAccessForItem) + { + return "Claude Keychain access was denied. CodexBar won’t ask again for 6 hours in Auto mode. " + + "Switch Claude Usage source to Web/CLI, or allow access in Keychain Access." + } + #endif + return "Claude OAuth keychain error: \(status)" + case let .readFailed(message): + return "Claude OAuth credentials read failed: \(message)" + case let .refreshFailed(message): + return "Claude OAuth token refresh failed: \(message). Run `claude` to re-authenticate." case .noRefreshToken: - "Claude OAuth refresh token missing. Run `claude` to authenticate." + return "Claude OAuth refresh token missing. Run `claude` to authenticate." } } } @@ -124,21 +138,23 @@ public enum ClaudeOAuthCredentialsStore { // Can be overridden via environment variable if Anthropic ever changes it. public static let defaultOAuthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" public static let environmentClientIDKey = "CODEXBAR_CLAUDE_OAUTH_CLIENT_ID" - private static let tokenRefreshEndpoint = "https://console.anthropic.com/v1/oauth/token" + private static let tokenRefreshEndpoint = "https://platform.claude.com/v1/oauth/token" private static var oauthClientID: String { - ProcessInfo.processInfo.environment[self.environmentClientIDKey]?.trimmingCharacters(in: .whitespacesAndNewlines) + ProcessInfo.processInfo.environment[self.environmentClientIDKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? self.defaultOAuthClientID } private static let log = CodexBarLog.logger(LogCategories.claudeUsage) private static let fileFingerprintKey = "ClaudeOAuthCredentialsFileFingerprintV1" + private static let claudeKeychainPromptLock = NSLock() #if DEBUG - private nonisolated(unsafe) static var keychainAccessOverride: Bool? - static func setKeychainAccessOverrideForTesting(_ disabled: Bool?) { - self.keychainAccessOverride = disabled - } + private nonisolated(unsafe) static var keychainAccessOverride: Bool? + static func setKeychainAccessOverrideForTesting(_ disabled: Bool?) { + self.keychainAccessOverride = disabled + } #endif private struct CredentialsFileFingerprint: Codable, Equatable, Sendable { @@ -153,25 +169,40 @@ public enum ClaudeOAuthCredentialsStore { private nonisolated(unsafe) static var credentialsURLOverride: URL? // In-memory cache (nonisolated for synchronous access) + private static let memoryCacheLock = NSLock() private nonisolated(unsafe) static var cachedCredentials: ClaudeOAuthCredentials? private nonisolated(unsafe) static var cacheTimestamp: Date? private static let memoryCacheValidityDuration: TimeInterval = 1800 + private static func readMemoryCache() -> (credentials: ClaudeOAuthCredentials?, timestamp: Date?) { + self.memoryCacheLock.lock() + defer { self.memoryCacheLock.unlock() } + return (self.cachedCredentials, self.cacheTimestamp) + } + + private static func writeMemoryCache(credentials: ClaudeOAuthCredentials?, timestamp: Date?) { + self.memoryCacheLock.lock() + self.cachedCredentials = credentials + self.cacheTimestamp = timestamp + self.memoryCacheLock.unlock() + } + public static func load( environment: [String: String] = ProcessInfo.processInfo.environment, - allowKeychainPrompt: Bool = true - ) throws -> ClaudeOAuthCredentials { + allowKeychainPrompt: Bool = true, + respectKeychainPromptCooldown: Bool = false) throws -> ClaudeOAuthCredentials + { if let credentials = self.loadFromEnvironment(environment) { return credentials } _ = self.invalidateCacheIfCredentialsFileChanged() - _ = self.invalidateCacheIfClaudeKeychainChanged() - if let cached = self.cachedCredentials, - let timestamp = self.cacheTimestamp, - Date().timeIntervalSince(timestamp) < self.memoryCacheValidityDuration, - !cached.isExpired + let memory = self.readMemoryCache() + if let cached = memory.credentials, + let timestamp = memory.timestamp, + Date().timeIntervalSince(timestamp) < self.memoryCacheValidityDuration, + !cached.isExpired { return cached } @@ -181,13 +212,12 @@ public enum ClaudeOAuthCredentialsStore { // 2. Try CodexBar's keychain cache (no prompts) switch KeychainCacheStore.load(key: self.cacheKey, as: CacheEntry.self) { - case .found(let entry): + case let .found(entry): if let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) { if creds.isExpired { expiredCredentials = creds } else { - self.cachedCredentials = creds - self.cacheTimestamp = Date() + self.writeMemoryCache(credentials: creds, timestamp: Date()) return creds } } else { @@ -206,8 +236,7 @@ public enum ClaudeOAuthCredentialsStore { if creds.isExpired { expiredCredentials = creds } else { - self.cachedCredentials = creds - self.cacheTimestamp = Date() + self.writeMemoryCache(credentials: creds, timestamp: Date()) self.saveToCacheKeychain(fileData) return creds } @@ -223,26 +252,29 @@ public enum ClaudeOAuthCredentialsStore { // 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 { - // Try without prompting - if let keychainData = try? self.loadFromClaudeKeychainWithoutPrompt() { + let promptAllowed = !respectKeychainPromptCooldown || ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() + if promptAllowed { + // Some macOS configurations still show the system keychain prompt even for our "silent" probes. + // Show the in-app pre-alert once before any Claude keychain access attempt when prompting is allowed. + self.claudeKeychainPromptLock.lock() + defer { self.claudeKeychainPromptLock.unlock() } + KeychainPromptHandler.handler?( + KeychainPromptContext( + kind: .claudeOAuth, + service: self.claudeKeychainService, + account: nil)) do { + let keychainData = try self.loadFromClaudeKeychain() let creds = try ClaudeOAuthCredentials.parse(data: keychainData) - self.cachedCredentials = creds - self.cacheTimestamp = Date() + self.writeMemoryCache(credentials: creds, timestamp: Date()) self.saveToCacheKeychain(keychainData) return creds + } catch let error as ClaudeOAuthCredentialsError { + if case .notFound = error { + // Ignore missing entry + } else { + lastError = error + } } catch { lastError = error } @@ -262,9 +294,13 @@ public enum ClaudeOAuthCredentialsStore { /// unless they switch accounts. public static func loadWithAutoRefresh( environment: [String: String] = ProcessInfo.processInfo.environment, - allowKeychainPrompt: Bool = true - ) async throws -> ClaudeOAuthCredentials { - let credentials = try self.load(environment: environment, allowKeychainPrompt: allowKeychainPrompt) + allowKeychainPrompt: Bool = true, + respectKeychainPromptCooldown: Bool = false) async throws -> ClaudeOAuthCredentials + { + let credentials = try self.load( + environment: environment, + allowKeychainPrompt: allowKeychainPrompt, + respectKeychainPromptCooldown: respectKeychainPromptCooldown) // If not expired, return as-is guard credentials.isExpired else { @@ -283,8 +319,7 @@ public enum ClaudeOAuthCredentialsStore { let refreshed = try await self.refreshAccessToken( refreshToken: refreshToken, existingScopes: credentials.scopes, - existingRateLimitTier: credentials.rateLimitTier - ) + existingRateLimitTier: credentials.rateLimitTier) self.log.info("Token refresh successful, expires in \(refreshed.expiresIn ?? 0) seconds") return refreshed } catch { @@ -298,8 +333,8 @@ public enum ClaudeOAuthCredentialsStore { public static func refreshAccessToken( refreshToken: String, existingScopes: [String], - existingRateLimitTier: String? - ) async throws -> ClaudeOAuthCredentials { + existingRateLimitTier: String?) async throws -> ClaudeOAuthCredentials + { guard let url = URL(string: self.tokenRefreshEndpoint) else { throw ClaudeOAuthCredentialsError.refreshFailed("Invalid token endpoint URL") } @@ -307,14 +342,16 @@ public enum ClaudeOAuthCredentialsStore { var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: String] = [ - "grant_type": "refresh_token", - "refresh_token": refreshToken, - "client_id": self.oauthClientID, + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "grant_type", value: "refresh_token"), + URLQueryItem(name: "refresh_token", value: refreshToken), + URLQueryItem(name: "client_id", value: self.oauthClientID), ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8) let (data, response) = try await URLSession.shared.data(for: request) @@ -323,13 +360,12 @@ public enum ClaudeOAuthCredentialsStore { } guard http.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "Unknown error" if http.statusCode == 401 || http.statusCode == 400 { - // Refresh token is invalid/expired - user needs to re-authenticate + // Refresh token is invalid/expired, or the request was rejected. self.invalidateCache() - throw ClaudeOAuthCredentialsError.refreshFailed("Refresh token expired (\(http.statusCode))") + throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(http.statusCode)") } - throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(http.statusCode): \(body)") + throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(http.statusCode)") } // Parse the token response @@ -342,32 +378,34 @@ public enum ClaudeOAuthCredentialsStore { refreshToken: tokenResponse.refreshToken ?? refreshToken, expiresAt: expiresAt, scopes: existingScopes, - rateLimitTier: existingRateLimitTier - ) + rateLimitTier: existingRateLimitTier) // Save to CodexBar's keychain cache (not Claude's keychain) self.saveRefreshedCredentialsToCache(newCredentials) // Update in-memory cache - self.cachedCredentials = newCredentials - self.cacheTimestamp = Date() + self.writeMemoryCache(credentials: newCredentials, timestamp: Date()) return newCredentials } /// Save refreshed credentials to CodexBar's keychain cache private static func saveRefreshedCredentialsToCache(_ credentials: ClaudeOAuthCredentials) { - // Build the same JSON structure that Claude CLI uses - let oauthData: [String: Any] = [ - "claudeAiOauth": [ - "accessToken": credentials.accessToken, - "refreshToken": credentials.refreshToken as Any, - "expiresAt": (credentials.expiresAt?.timeIntervalSince1970 ?? 0) * 1000, - "scopes": credentials.scopes, - "rateLimitTier": credentials.rateLimitTier as Any, - ], + var oauth: [String: Any] = [ + "accessToken": credentials.accessToken, + "expiresAt": (credentials.expiresAt?.timeIntervalSince1970 ?? 0) * 1000, + "scopes": credentials.scopes, ] + if let refreshToken = credentials.refreshToken { + oauth["refreshToken"] = refreshToken + } + if let rateLimitTier = credentials.rateLimitTier { + oauth["rateLimitTier"] = rateLimitTier + } + + let oauthData: [String: Any] = ["claudeAiOauth": oauth] + guard let jsonData = try? JSONSerialization.data(withJSONObject: oauthData) else { self.log.error("Failed to serialize refreshed credentials for cache") return @@ -415,111 +453,139 @@ public enum ClaudeOAuthCredentialsStore { return true } - /// Check if Claude's keychain has different credentials than our cache - /// and invalidate if so (detects account switches when no file exists) - @discardableResult - public static func invalidateCacheIfClaudeKeychainChanged() -> Bool { - // Only check if keychain access is allowed - #if os(macOS) - guard self.keychainAccessAllowed else { return false } - - // Check if we would need to prompt the user - if so, skip this check - // to avoid unexpected prompts during cache validation - if case .interactionRequired = - KeychainAccessPreflight - .checkGenericPassword(service: self.claudeKeychainService, account: nil) - { - return false - } - - // Load cached credentials from CodexBar's keychain - guard - case .found(let entry) = KeychainCacheStore.load( - key: self.cacheKey, as: CacheEntry.self), - let cachedCreds = try? ClaudeOAuthCredentials.parse(data: entry.data) - else { - return false - } - - // Load current credentials from Claude's keychain - guard let keychainData = try? self.loadFromClaudeKeychainWithoutPrompt(), - let keychainCreds = try? ClaudeOAuthCredentials.parse(data: keychainData) - else { - return false - } - - // Compare access tokens - if different, the user switched accounts - guard cachedCreds.accessToken != keychainCreds.accessToken else { return false } - - self.log.info( - "Claude keychain credentials changed (account switch detected); 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 - self.cacheTimestamp = nil + self.writeMemoryCache(credentials: nil, timestamp: nil) self.clearCacheKeychain() } /// Check if CodexBar has cached credentials (in memory or keychain cache) - public static func hasCachedCredentials() -> Bool { + public static func hasCachedCredentials(environment: [String: String] = ProcessInfo.processInfo + .environment) -> Bool + { + func isRefreshableOrValid(_ creds: ClaudeOAuthCredentials) -> Bool { + if !creds.isExpired { return true } + let refreshToken = creds.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !refreshToken.isEmpty + } + + if let creds = self.loadFromEnvironment(environment), + isRefreshableOrValid(creds) + { + return true + } + // Check in-memory cache - if let timestamp = self.cacheTimestamp, - Date().timeIntervalSince(timestamp) < self.memoryCacheValidityDuration + let memory = self.readMemoryCache() + if let timestamp = memory.timestamp, + let cached = memory.credentials, + Date().timeIntervalSince(timestamp) < self.memoryCacheValidityDuration, + isRefreshableOrValid(cached) { return true } - // Check keychain cache + // Check keychain cache (must be parseable; may be expired but still refreshable without prompting) switch KeychainCacheStore.load(key: self.cacheKey, as: CacheEntry.self) { - case .found: return true - default: return false + case let .found(entry): + guard let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) else { return false } + return isRefreshableOrValid(creds) + default: + break } + + // Check credentials file (no prompts) + if let fileData = try? self.loadFromFile(), + let creds = try? ClaudeOAuthCredentials.parse(data: fileData), + isRefreshableOrValid(creds) + { + return true + } + return false + } + + public static func hasClaudeKeychainCredentialsWithoutPrompt() -> Bool { + #if os(macOS) + if !self.keychainAccessAllowed { return false } + + if !self.claudeKeychainCandidatesWithoutPrompt().isEmpty { + return true + } + + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.claudeKeychainService, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess, errSecInteractionNotAllowed: + return true + case errSecUserCanceled, errSecAuthFailed, errSecNoAccessForItem: + // Treat denial as "not available" and record a cooldown to avoid prompt storms in Auto mode. + ClaudeOAuthKeychainAccessGate.recordDenied() + return false + default: + return false + } + #else + return false + #endif } public static func loadFromClaudeKeychain() throws -> Data { #if os(macOS) - if !self.keychainAccessAllowed { - throw ClaudeOAuthCredentialsError.notFound - } - if case .interactionRequired = - KeychainAccessPreflight - .checkGenericPassword(service: self.claudeKeychainService, account: nil) + if !self.keychainAccessAllowed { + throw ClaudeOAuthCredentialsError.notFound + } + let candidates = self.claudeKeychainCandidatesWithoutPrompt() + if let newest = candidates.first { + // Attempt a silent read first. + if let data = try self.loadClaudeKeychainData(candidate: newest, allowKeychainPrompt: false), + !data.isEmpty { - KeychainPromptHandler.handler?( - KeychainPromptContext( - kind: .claudeOAuth, - service: self.claudeKeychainService, - account: nil)) + return data } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: self.claudeKeychainService, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - switch status { - case errSecSuccess: - guard let data = result as? Data else { - throw ClaudeOAuthCredentialsError.readFailed("Keychain item is empty.") + + do { + if let data = try self.loadClaudeKeychainData(candidate: newest, allowKeychainPrompt: true), + !data.isEmpty + { + return data + } + } catch let error as ClaudeOAuthCredentialsError { + if case .keychainError = error { + ClaudeOAuthKeychainAccessGate.recordDenied() } - if data.isEmpty { throw ClaudeOAuthCredentialsError.notFound } + throw error + } + } + + if let data = try self.loadClaudeKeychainLegacyData(allowKeychainPrompt: false), + !data.isEmpty + { + return data + } + + // Fallback: legacy query (may pick an arbitrary duplicate). + do { + if let data = try self.loadClaudeKeychainLegacyData(allowKeychainPrompt: true), + !data.isEmpty + { return data - case errSecItemNotFound: - throw ClaudeOAuthCredentialsError.notFound - default: - throw ClaudeOAuthCredentialsError.keychainError(Int(status)) } + } catch let error as ClaudeOAuthCredentialsError { + if case .keychainError = error { + ClaudeOAuthKeychainAccessGate.recordDenied() + } + throw error + } + throw ClaudeOAuthCredentialsError.notFound #else - throw ClaudeOAuthCredentialsError.notFound + throw ClaudeOAuthCredentialsError.notFound #endif } @@ -528,43 +594,121 @@ public enum ClaudeOAuthCredentialsStore { try self.loadFromClaudeKeychain() } - /// Load from Claude's keychain without triggering a user prompt. - /// Returns nil if interaction would be required. - private static func loadFromClaudeKeychainWithoutPrompt() throws -> Data? { - #if os(macOS) - // Use LAContext with interactionNotAllowed to prevent any prompts - 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: - // Keychain requires user interaction, skip silently - return nil - default: - return nil + #if os(macOS) + private struct ClaudeKeychainCandidate: Sendable { + let persistentRef: Data + let account: String? + let modifiedAt: Date? + let createdAt: Date? + } + + private static func claudeKeychainCandidatesWithoutPrompt() -> [ClaudeKeychainCandidate] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.claudeKeychainService, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + kSecReturnPersistentRef as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return [] } + guard let rows = result as? [[String: Any]], !rows.isEmpty else { return [] } + + let candidates: [ClaudeKeychainCandidate] = rows.compactMap { row in + guard let persistentRef = row[kSecValuePersistentRef as String] as? Data else { return nil } + return ClaudeKeychainCandidate( + persistentRef: persistentRef, + account: row[kSecAttrAccount as String] as? String, + modifiedAt: row[kSecAttrModificationDate as String] as? Date, + createdAt: row[kSecAttrCreationDate as String] as? Date) + } + + return candidates.sorted { lhs, rhs in + let lhsDate = lhs.modifiedAt ?? lhs.createdAt ?? Date.distantPast + let rhsDate = rhs.modifiedAt ?? rhs.createdAt ?? Date.distantPast + return lhsDate > rhsDate + } + } + + private static func loadClaudeKeychainData( + candidate: ClaudeKeychainCandidate, + allowKeychainPrompt: Bool) throws -> Data? + { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecValuePersistentRef as String: candidate.persistentRef, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + + if !allowKeychainPrompt { + KeychainNoUIQuery.apply(to: &query) + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + return result as? Data + case errSecItemNotFound: + return nil + case errSecInteractionNotAllowed: + if allowKeychainPrompt { + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(Int(status)) } - #else return nil - #endif + case errSecUserCanceled, errSecAuthFailed: + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(Int(status)) + case errSecNoAccessForItem: + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(Int(status)) + default: + throw ClaudeOAuthCredentialsError.keychainError(Int(status)) + } } + private static func loadClaudeKeychainLegacyData(allowKeychainPrompt: Bool) throws -> Data? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.claudeKeychainService, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + + if !allowKeychainPrompt { + KeychainNoUIQuery.apply(to: &query) + } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + return result as? Data + case errSecItemNotFound: + return nil + case errSecInteractionNotAllowed: + if allowKeychainPrompt { + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(Int(status)) + } + return nil + case errSecUserCanceled, errSecAuthFailed: + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(Int(status)) + case errSecNoAccessForItem: + ClaudeOAuthKeychainAccessGate.recordDenied() + throw ClaudeOAuthCredentialsError.keychainError(Int(status)) + default: + throw ClaudeOAuthCredentialsError.keychainError(Int(status)) + } + } + #endif + private static func loadFromEnvironment(_ environment: [String: String]) -> ClaudeOAuthCredentials? { @@ -580,9 +724,9 @@ public enum ClaudeOAuthCredentialsStore { guard let raw = environment[self.environmentScopesKey] else { return ["user:profile"] } let parsed = raw - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } return parsed.isEmpty ? ["user:profile"] : parsed }() @@ -609,15 +753,15 @@ public enum ClaudeOAuthCredentialsStore { private static var keychainAccessAllowed: Bool { #if DEBUG - if let override = self.keychainAccessOverride { - return !override - } + if let override = self.keychainAccessOverride { + return !override + } #endif return !KeychainAccessGate.isDisabled } private static func credentialsFileURL() -> URL { - self.credentialsURLOverride ?? Self.defaultCredentialsURL() + self.credentialsURLOverride ?? self.defaultCredentialsURL() } private static func loadFileFingerprint() -> CredentialsFileFingerprint? { @@ -648,9 +792,9 @@ public enum ClaudeOAuthCredentialsStore { } #if DEBUG - static func _resetCredentialsFileTrackingForTesting() { - UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) - } + static func _resetCredentialsFileTrackingForTesting() { + UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) + } #endif private static func defaultCredentialsURL() -> URL { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainAccessGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainAccessGate.swift new file mode 100644 index 00000000..4703fee9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainAccessGate.swift @@ -0,0 +1,87 @@ +import Foundation + +#if os(macOS) +import os.lock + +public enum ClaudeOAuthKeychainAccessGate { + private struct State { + var loaded = false + var deniedUntil: Date? + } + + private static let lock = OSAllocatedUnfairLock(initialState: State()) + private static let defaultsKey = "claudeOAuthKeychainDeniedUntil" + private static let cooldownInterval: TimeInterval = 60 * 60 * 6 + + public static func shouldAllowPrompt(now: Date = Date()) -> Bool { + guard !KeychainAccessGate.isDisabled else { return false } + return self.lock.withLock { state in + self.loadIfNeeded(&state) + if let deniedUntil = state.deniedUntil { + if deniedUntil > now { + return false + } + state.deniedUntil = nil + self.persist(state) + } + return true + } + } + + public static func recordDenied(now: Date = Date()) { + let deniedUntil = now.addingTimeInterval(self.cooldownInterval) + self.lock.withLock { state in + self.loadIfNeeded(&state) + state.deniedUntil = deniedUntil + self.persist(state) + } + } + + #if DEBUG + public static func resetForTesting() { + self.lock.withLock { state in + state.loaded = false + state.deniedUntil = nil + UserDefaults.standard.removeObject(forKey: self.defaultsKey) + } + } + + public static func resetInMemoryForTesting() { + self.lock.withLock { state in + state.loaded = false + state.deniedUntil = nil + } + } + #endif + + private static func loadIfNeeded(_ state: inout State) { + guard !state.loaded else { return } + state.loaded = true + if let raw = UserDefaults.standard.object(forKey: self.defaultsKey) as? Double { + state.deniedUntil = Date(timeIntervalSince1970: raw) + } + } + + private static func persist(_ state: State) { + if let deniedUntil = state.deniedUntil { + UserDefaults.standard.set(deniedUntil.timeIntervalSince1970, forKey: self.defaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: self.defaultsKey) + } + } +} +#else +public enum ClaudeOAuthKeychainAccessGate { + public static func shouldAllowPrompt(now _: Date = Date()) -> Bool { + true + } + + public static func recordDenied(now _: Date = Date()) {} + + #if DEBUG + public static func resetForTesting() {} + + public static func resetInMemoryForTesting() {} + #endif +} +#endif diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index c0e2cf5e..9853c73e 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -136,17 +136,26 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .oauth func isAvailable(_ context: ProviderFetchContext) async -> Bool { - // Don't prompt for keychain access during availability check - guard let creds = try? ClaudeOAuthCredentialsStore.load( + // In OAuth-only mode, allow the fetch to run and prompt once when needed. + guard context.sourceMode == .auto else { return true } + + // Prefer OAuth in Auto mode only when it’s plausibly usable: + // - we can load credentials without prompting (env / CodexBar cache / credentials file) AND they meet the + // scope requirement, or + // - Claude Code has stored OAuth creds in Keychain and we may be able to bootstrap (one prompt max). + 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) + { + let hasRequiredScope = creds.scopes.contains("user:profile") + if hasRequiredScope { + if !creds.isExpired { return true } + let refreshToken = creds.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !refreshToken.isEmpty { return true } + } } - return true + guard ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() else { return false } + return ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt() } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { @@ -154,6 +163,7 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { browserDetection: context.browserDetection, environment: context.env, dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: context.sourceMode == .auto, useWebExtras: false) let usage = try await fetcher.loadLatestUsage(model: "sonnet") return self.makeResult( @@ -162,7 +172,9 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { } func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { - context.runtime == .app && (context.sourceMode == .auto || context.sourceMode == .oauth) + // In Auto mode, fall back to CLI if OAuth fails (e.g. user cancels keychain prompt or auth breaks). + // In OAuth-only mode, allow Web/CLI fallback as a safety net. + context.runtime == .app && (context.sourceMode == .oauth || context.sourceMode == .auto) } fileprivate static func snapshot(from usage: ClaudeUsageSnapshot) -> UsageSnapshot { @@ -243,7 +255,7 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { sourceLabel: "claude") } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { false } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index b7a149b6..989edb94 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -26,8 +26,8 @@ public struct ClaudeUsageSnapshot: Sendable { accountEmail: String?, accountOrganization: String?, loginMethod: String?, - rawText: String? - ) { + rawText: String?) + { self.primary = primary self.secondary = secondary self.opus = opus @@ -49,9 +49,9 @@ public enum ClaudeUsageError: LocalizedError, Sendable { switch self { case .claudeNotInstalled: "Claude CLI is not installed. Install it from https://docs.claude.ai/claude-code." - case .parseFailed(let details): + case let .parseFailed(details): "Could not parse Claude usage: \(details)" - case .oauthFailed(let details): + case let .oauthFailed(details): details } } @@ -60,6 +60,7 @@ public enum ClaudeUsageError: LocalizedError, Sendable { public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private let environment: [String: String] private let dataSource: ClaudeUsageDataSource + private let oauthKeychainPromptCooldownEnabled: Bool private let useWebExtras: Bool private let manualCookieHeader: String? private let keepCLISessionsAlive: Bool @@ -75,13 +76,15 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { browserDetection: BrowserDetection, environment: [String: String] = ProcessInfo.processInfo.environment, dataSource: ClaudeUsageDataSource = .oauth, + oauthKeychainPromptCooldownEnabled: Bool = false, useWebExtras: Bool = false, manualCookieHeader: String? = nil, - keepCLISessionsAlive: Bool = false - ) { + keepCLISessionsAlive: Bool = false) + { self.browserDetection = browserDetection self.environment = environment self.dataSource = dataSource + self.oauthKeychainPromptCooldownEnabled = oauthKeychainPromptCooldownEnabled self.useWebExtras = useWebExtras self.manualCookieHeader = manualCookieHeader self.keepCLISessionsAlive = keepCLISessionsAlive @@ -171,8 +174,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let timePart = parts.first?.trimmingCharacters(in: .whitespaces) let tzPart = parts.count > 1 - ? parts[1].replacingOccurrences(of: ")", with: "").trimmingCharacters(in: .whitespaces) - : nil + ? parts[1].replacingOccurrences(of: ")", with: "").trimmingCharacters(in: .whitespaces) + : nil let tz = tzPart.flatMap(TimeZone.init(identifier:)) let formats = ["ha", "h:mma", "MMM d 'at' ha", "MMM d 'at' h:mma"] for format in formats { @@ -197,8 +200,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { options: TTYCommandRunner.Options( timeout: 5.0, extraArgs: ["--allowed-tools", "", "--version"], - initialDelay: 0.0) - ).text + initialDelay: 0.0)).text return TextParsing.stripANSICodes(out).trimmingCharacters(in: .whitespacesAndNewlines) } catch { return nil @@ -214,10 +216,10 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let weekly = snap.secondary?.remainingPercent ?? -1 let primary = snap.primary.remainingPercent return """ - session_left=\(primary) weekly_left=\(weekly) - opus_left=\(opus) email \(email) org \(org) - \(snap) - """ + session_left=\(primary) weekly_left=\(weekly) + opus_left=\(opus) email \(email) org \(org) + \(snap) + """ } catch { return "Probe failed: \(error)" } @@ -226,7 +228,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 { @@ -234,6 +238,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } else { ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.browserDetection) } + let hasCLI = TTYCommandRunner.which("claude") != nil if hasOAuthCredentials { var snap = try await self.loadViaOAuth() snap = await self.applyWebExtrasIfNeeded(to: snap) @@ -242,7 +247,16 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { if hasWebSession { return try await self.loadViaWebAPI() } - var snap = try await self.loadViaPTY(model: model, timeout: 10) + if hasCLI { + do { + var snap = try await self.loadViaPTY(model: model, timeout: 10) + snap = await self.applyWebExtrasIfNeeded(to: snap) + return snap + } catch { + // CLI failed; OAuth is the last resort. + } + } + var snap = try await self.loadViaOAuth() snap = await self.applyWebExtrasIfNeeded(to: snap) return snap case .oauth: @@ -269,19 +283,23 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private func loadViaOAuth() async throws -> ClaudeUsageSnapshot { do { // Allow keychain prompt when no cached credentials exist (bootstrap case) - let hasCache = ClaudeOAuthCredentialsStore.hasCachedCredentials() + let hasCache = ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.environment) + let promptGateAllowsPrompt = ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() + let allowKeychainPrompt = + !hasCache + && (!self.oauthKeychainPromptCooldownEnabled || promptGateAllowsPrompt) // Use loadWithAutoRefresh to automatically refresh expired tokens // This saves the refreshed token to CodexBar's keychain cache, // so users won't be prompted for keychain access again. let creds = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( environment: self.environment, - allowKeychainPrompt: !hasCache) + allowKeychainPrompt: allowKeychainPrompt, + respectKeychainPromptCooldown: self.oauthKeychainPromptCooldownEnabled) // The usage endpoint requires user:profile scope. if !creds.scopes.contains("user:profile") { throw ClaudeUsageError.oauthFailed( "Claude OAuth token missing 'user:profile' scope (has: \(creds.scopes.joined(separator: ", "))). " - + "Run `claude setup-token` to re-generate credentials, or switch Claude Source to Web/CLI." - ) + + "Run `claude setup-token` to re-generate credentials, or switch Claude Source to Web/CLI.") } let usage = try await ClaudeOAuthUsageFetcher.fetchUsage(accessToken: creds.accessToken) return try Self.mapOAuthUsage(usage, credentials: creds) @@ -291,14 +309,13 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { throw ClaudeUsageError.oauthFailed(error.localizedDescription) } catch let error as ClaudeOAuthFetchError { ClaudeOAuthCredentialsStore.invalidateCache() - if case .serverError(let statusCode, let body) = error, - statusCode == 403, - body?.contains("user:profile") ?? false + if case let .serverError(statusCode, body) = error, + statusCode == 403, + body?.contains("user:profile") ?? false { throw ClaudeUsageError.oauthFailed( "Claude OAuth token does not meet scope requirement 'user:profile'. " - + "Run `claude setup-token` to re-generate credentials, or switch Claude Source to Web/CLI." - ) + + "Run `claude setup-token` to re-generate credentials, or switch Claude Source to Web/CLI.") } throw ClaudeUsageError.oauthFailed(error.localizedDescription) } catch { @@ -308,11 +325,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private static func mapOAuthUsage( _ usage: OAuthUsageResponse, - credentials: ClaudeOAuthCredentials - ) throws -> ClaudeUsageSnapshot { + credentials: ClaudeOAuthCredentials) throws -> ClaudeUsageSnapshot + { func makeWindow(_ window: OAuthUsageWindow?, windowMinutes: Int?) -> RateWindow? { guard let window, - let utilization = window.utilization + let utilization = window.utilization else { return nil } let resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) let resetDescription = resetDate.map(Self.formatResetDate) @@ -349,11 +366,11 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private static func oauthExtraUsageCost( _ extra: OAuthExtraUsage?, - loginMethod: String? - ) -> ProviderCostSnapshot? { + loginMethod: String?) -> ProviderCostSnapshot? + { guard let extra, extra.isEnabled == true else { return nil } guard let used = extra.usedCredits, - let limit = extra.monthlyLimit + let limit = extra.monthlyLimit else { return nil } let currency = extra.currency?.trimmingCharacters(in: .whitespacesAndNewlines) let code = (currency?.isEmpty ?? true) ? "USD" : currency! @@ -369,8 +386,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } private static func normalizeClaudeExtraUsageAmounts(used: Double, limit: Double) -> ( - used: Double, limit: Double - ) { + used: Double, limit: Double) + { // Claude's OAuth API returns values in cents (minor units), same as the Web API. // Always convert to dollars (major units) for display consistency. // This removes the fragile heuristic that could fail on non-whole cent values @@ -383,8 +400,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { /// Scale down again when limits are implausible. private static func rescaleClaudeExtraUsageCostIfNeeded( _ cost: ProviderCostSnapshot?, - loginMethod: String? - ) -> ProviderCostSnapshot? { + loginMethod: String?) -> ProviderCostSnapshot? + { guard let cost else { return nil } guard let threshold = Self.extraUsageRescaleThreshold(for: loginMethod) else { return cost } guard cost.limit >= threshold else { return cost } @@ -401,8 +418,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private static func extraUsageRescaleThreshold(for loginMethod: String?) -> Double? { let normalized = loginMethod? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() ?? "" + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" if normalized.contains("enterprise") { return nil } return 1000 } @@ -425,8 +442,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { Self.log.debug(msg) } } else { - try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: self.browserDetection) { - msg in + try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: self.browserDetection) { msg in Self.log.debug(msg) } } @@ -534,8 +550,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } } else { try await ClaudeWebAPIFetcher.fetchUsage( - browserDetection: self.browserDetection - ) { msg in + browserDetection: self.browserDetection) + { msg in Self.log.debug(msg) } } @@ -576,7 +592,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { guard let path = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), - !path.isEmpty + !path.isEmpty else { return nil } return path } @@ -596,28 +612,28 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } #if DEBUG - extension ClaudeUsageFetcher { - public static func _mapOAuthUsageForTesting( - _ data: Data, - rateLimitTier: String? = nil - ) throws -> ClaudeUsageSnapshot { - let usage = try ClaudeOAuthUsageFetcher.decodeUsageResponse(data) - let creds = ClaudeOAuthCredentials( - accessToken: "test", - refreshToken: nil, - expiresAt: Date().addingTimeInterval(3600), - scopes: [], - rateLimitTier: rateLimitTier) - return try Self.mapOAuthUsage(usage, credentials: creds) - } +extension ClaudeUsageFetcher { + public static func _mapOAuthUsageForTesting( + _ data: Data, + rateLimitTier: String? = nil) throws -> ClaudeUsageSnapshot + { + let usage = try ClaudeOAuthUsageFetcher.decodeUsageResponse(data) + let creds = ClaudeOAuthCredentials( + accessToken: "test", + refreshToken: nil, + expiresAt: Date().addingTimeInterval(3600), + scopes: [], + rateLimitTier: rateLimitTier) + return try Self.mapOAuthUsage(usage, credentials: creds) + } - public static func _rescaleExtraUsageForTesting( - _ cost: ProviderCostSnapshot?, - snapshotLoginMethod: String?, - webLoginMethod: String? - ) -> ProviderCostSnapshot? { - let loginMethod = snapshotLoginMethod ?? webLoginMethod - return Self.rescaleClaudeExtraUsageCostIfNeeded(cost, loginMethod: loginMethod) - } + public static func _rescaleExtraUsageForTesting( + _ cost: ProviderCostSnapshot?, + snapshotLoginMethod: String?, + webLoginMethod: String?) -> ProviderCostSnapshot? + { + let loginMethod = snapshotLoginMethod ?? webLoginMethod + return Self.rescaleClaudeExtraUsageCostIfNeeded(cost, loginMethod: loginMethod) } +} #endif diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index ff4c700a..62d579aa 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -22,6 +22,8 @@ struct BrowserDetectionTests { @Test func filterPreservesOrder() { + BrowserCookieAccessGate.resetForTesting() + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try? FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: temp) } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 70d4033c..57207d78 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -4,14 +4,18 @@ import Testing @Suite(.serialized) struct ClaudeOAuthCredentialsStoreTests { - private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data { + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let refreshField: String = { + guard let refreshToken else { return "" } + return ",\n \"refreshToken\": \"\(refreshToken)\"" + }() let json = """ { "claudeAiOauth": { "accessToken": "\(accessToken)", "expiresAt": \(millis), - "scopes": ["user:profile"] + "scopes": ["user:profile"]\(refreshField) } } """ @@ -60,7 +64,6 @@ struct ClaudeOAuthCredentialsStoreTests { #expect(creds.isExpired == false) } - @Test func invalidatesCacheWhenCredentialsFileChanges() throws { KeychainCacheStore.setTestStoreForTesting(true) @@ -98,6 +101,7 @@ struct ClaudeOAuthCredentialsStoreTests { let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) #expect(creds.accessToken == "second") } + @Test func returnsExpiredFileWhenNoOtherSources() throws { KeychainCacheStore.setTestStoreForTesting(true) @@ -128,4 +132,77 @@ struct ClaudeOAuthCredentialsStoreTests { #expect(creds.accessToken == "expired-only") #expect(creds.isExpired == true) } + + @Test + func hasCachedCredentials_returnsFalseForExpiredUnrefreshableCacheEntry() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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) } + + ClaudeOAuthCredentialsStore.invalidateCache() + + let expiredData = self.makeCredentialsData( + accessToken: "expired-no-refresh", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: nil) + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: expiredData, storedAt: Date()) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == false) + } + + @Test + func hasCachedCredentials_returnsTrueForExpiredRefreshableCacheEntry() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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) } + + ClaudeOAuthCredentialsStore.invalidateCache() + + let expiredData = self.makeCredentialsData( + accessToken: "expired-refreshable", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "refresh") + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: expiredData, storedAt: Date()) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == true) + } + + @Test + func hasCachedCredentials_returnsFalseForExpiredUnrefreshableCredentialsFile() throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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) } + + ClaudeOAuthCredentialsStore.invalidateCache() + + let expiredData = self.makeCredentialsData( + accessToken: "expired-file-no-refresh", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: nil) + try expiredData.write(to: fileURL) + + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == false) + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift new file mode 100644 index 00000000..23aa139f --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthKeychainAccessGateTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthKeychainAccessGateTests { + @Test + func blocksUntilCooldownExpires() { + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 1000) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now)) + + ClaudeOAuthKeychainAccessGate.recordDenied(now: now) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1)) == false) + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 + 1))) + } + + @Test + func persistsDeniedUntil() { + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 2000) + ClaudeOAuthKeychainAccessGate.recordDenied(now: now) + + ClaudeOAuthKeychainAccessGate.resetInMemoryForTesting() + + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1)) == false) + } + + @Test + func respectsDebugDisableKeychainAccess() { + ClaudeOAuthKeychainAccessGate.resetForTesting() + defer { ClaudeOAuthKeychainAccessGate.resetForTesting() } + + let previous = KeychainAccessGate.isDisabled + KeychainAccessGate.isDisabled = true + defer { KeychainAccessGate.isDisabled = previous } + + #expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: Date()) == false) + } +} diff --git a/Tests/CodexBarTests/KeychainMigrationTests.swift b/Tests/CodexBarTests/KeychainMigrationTests.swift index b0e31dd9..a6357b11 100644 --- a/Tests/CodexBarTests/KeychainMigrationTests.swift +++ b/Tests/CodexBarTests/KeychainMigrationTests.swift @@ -17,17 +17,9 @@ struct KeychainMigrationTests { "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() - // Should not crash when resetting. - #expect(true) - } }