diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a62f58e31..1bfe9df1f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Plugins: run the QuickJS worker and TypeScript transpiler on Thread subclasses instead of Thread(block:) closures — binaries built with the Xcode 26.3 SDK inferred @MainActor on those blocks, and macOS runtimes that enforce dynamic isolation crashed (SIGTRAP) on the first plugin fetch; this was the deterministic macOS CI shard crash since #2775 and could crash shipped builds at runtime. - Plugins: the QuickJS HTTP/cookie bridge now starts a request's per-call timeout when the transport actually begins executing instead of when it is scheduled, so short deadlines (like OpenRouter's one-second key fast join) no longer fire spuriously under CPU load (refs #2778). - Codex: preserve recently modified cost-cache sessions across complete local calendar-day windows, avoiding needless rediscovery and rescans (#2764). Thanks @Yuxin-Qiao! +- Claude: restore OAuth usage on Claude Code 2.1.x via an explicit, default-off "Allow reading Claude Code's credentials" opt-in that reopens the direct Keychain read, freshness sync, and refresh verification together, plus an automatic Claude CLI usage fallback (labeled with reduced fidelity) when consent is off (#2634). Thanks @Astro-Han, @kes02, and @Komunikuji for the deep diagnostics! - Codex: price persisted usage from token classes when reports are read, so a cold rebuild racing the models.dev catalog can no longer permanently bake fallback rates into SQLite (#2772). - OpenRouter: keep optional key-quota enrichment on its one-second production fast join while making degraded results explicit and preventing loaded CI parity runs from mistaking the fallback snapshot for a golden mismatch (fixes #2778). - Codex: the SQLite cost store now writes each save cycle inside one transaction, so a crash or kill mid-save can never leave session rows updated against stale day aggregates — the previous state survives intact, matching the old JSON path's atomic file replace (refs #2760). diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 6157f2f32e..18321520bd 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -246,6 +246,11 @@ extension UsageMenuCardView.Model { return Self.mimoUsageNotes(input: input, subscriptionNotes: subscriptionNotes) } + if input.provider == .claude, input.snapshot?.dataConfidence == .percentOnly { + // CLI-scraped usage carries rendered percentages only; label the reduced fidelity honestly. + return [L("Usage via Claude CLI (limited detail)")] + subscriptionNotes + } + if let notes = self.apiProviderUsageNotes(input: input) { return notes + subscriptionNotes } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 94f75c5210..f268096874 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -23,6 +23,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeCookieSource _ = settings.claudeCookieHeader _ = settings.claudeOAuthKeychainPromptMode + _ = settings.claudeOAuthDirectKeychainReadAllowed _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled _ = settings.claudeSwapEnabled @@ -107,6 +108,23 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "claude-oauth-direct-keychain-read", + title: "Allow reading Claude Code's credentials", + subtitle: [ + "Reads Claude Code's Keychain item for OAuth usage; macOS may ask for permission.", + "Off: CodexBar never touches Claude Code's credentials and uses the Claude CLI instead.", + ].joined(separator: " "), + binding: Binding( + get: { context.settings.claudeOAuthDirectKeychainReadAllowed }, + set: { context.settings.claudeOAuthDirectKeychainReadAllowed = $0 }), + statusText: nil, + actions: [], + isVisible: nil, + isEnabled: { !context.settings.debugDisableKeychainAccess }, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", title: "Avoid Keychain prompts", @@ -321,6 +339,11 @@ struct ClaudeProviderImplementation: ProviderImplementation { func loginMenuAction(context: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { + if self.shouldOfferDirectKeychainReadConsent(context: context) { + // Terminal unreadable state (#2634/#2650): OAuth cannot recover until the user either opts in + // to reading Claude Code's Keychain item or usage arrives via the Claude CLI fallback. + return ("Allow reading Claude Code's credentials in Settings…", .settings) + } if self.shouldOpenBrowserForWebSessionError(context: context) { return ("Re-login at claude.ai", .loginToProvider(url: "https://claude.ai/")) } @@ -331,6 +354,14 @@ struct ClaudeProviderImplementation: ProviderImplementation { return (L("Sign in with Claude Code..."), .switchAccount(.claude)) } + @MainActor + private func shouldOfferDirectKeychainReadConsent(context: ProviderMenuLoginContext) -> Bool { + guard !context.settings.claudeOAuthDirectKeychainReadAllowed, + !context.settings.debugDisableKeychainAccess + else { return false } + return ClaudeOAuthUnreadableCredentialsError.matches(description: context.store.error(for: .claude)) + } + @MainActor private func shouldOpenBrowserForWebSessionError(context: ProviderMenuLoginContext) -> Bool { let settings = context.settings.claudeSettingsSnapshot(tokenOverride: nil) diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 687fdc65ae..cc7f35d76f 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -613,6 +613,30 @@ extension SettingsStore { } } + /// Explicit opt-in for reading Claude Code's own Keychain item (#2634). Feeds + /// `ClaudeOAuthDirectKeychainReadConsent`, the single consent source behind + /// `ClaudeOAuthCredentialsStore.keychainAccessAllowed`. + var claudeOAuthDirectKeychainReadAllowed: Bool { + get { self.defaultsState.claudeOAuthDirectKeychainReadAllowed } + set { + let wasAllowed = self.defaultsState.claudeOAuthDirectKeychainReadAllowed + self.defaultsState.claudeOAuthDirectKeychainReadAllowed = newValue + self.userDefaults.set(newValue, forKey: ClaudeOAuthDirectKeychainReadConsent.userDefaultsKey) + CodexBarLog.logger(LogCategories.settings).info( + "Claude direct Keychain read consent updated", + metadata: ["allowed": newValue ? "1" : "0"]) + if wasAllowed, !newValue { + // Revoking consent must also revoke what consent obtained: credentials copied from Claude + // Code's Keychain while consent was on live in CodexBar's memory and Keychain caches, and + // those caches are consulted before the direct-read gate. Advance the global revocation epoch + // before dropping the active cache so previously used profile caches also fail closed on lookup + // (CodexBar-owned state only — Claude Code's item is untouched). + ClaudeOAuthCredentialsStore.revokeDirectKeychainReadConsent() + } + self.noteBackgroundWorkSettingsChanged() + } + } + var claudeOAuthPromptFreeCredentialsEnabled: Bool { get { self.claudeOAuthKeychainPromptMode == .never } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index c2fba0f58f..0bd0973923 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -52,6 +52,7 @@ extension SettingsStore { _ = self.confettiOnSessionLimitResetsEnabled _ = self.confettiOnWeeklyLimitResetsEnabled _ = self.claudeOAuthKeychainPromptMode + _ = self.claudeOAuthDirectKeychainReadAllowed _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled _ = self.copilotBudgetExtrasEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index b0277ef06f..09425a7b08 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -492,6 +492,9 @@ extension SettingsStore { let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false let claudeOAuthKeychainReadStrategyRaw = Self.loadClaudeOAuthKeychainReadStrategyRaw(userDefaults: userDefaults) let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") + // Explicit consent for reading Claude Code's Keychain item (#2634). Default OFF; never enabled silently. + let claudeOAuthDirectKeychainReadAllowed = userDefaults.object( + forKey: ClaudeOAuthDirectKeychainReadConsent.userDefaultsKey) as? Bool ?? false let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true @@ -605,6 +608,7 @@ extension SettingsStore { menuBarShowsHighestUsage: menuBarShowsHighestUsage, claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, + claudeOAuthDirectKeychainReadAllowed: claudeOAuthDirectKeychainReadAllowed, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 5e49bc9a62..fc9d991821 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -54,6 +54,7 @@ struct SettingsDefaultsState { var menuBarShowsHighestUsage: Bool var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? + var claudeOAuthDirectKeychainReadAllowed: Bool var claudeWebExtrasEnabledRaw: Bool var showOptionalCreditsAndExtraUsage: Bool var claudeDailyRoutinesUsageVisible: Bool diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift index a4cb1dcaf7..1ad0bf71c4 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift @@ -28,6 +28,8 @@ extension ClaudeOAuthCredentialsStore { @TaskLocal static var taskClaudeKeychainFingerprintStoreOverride: ClaudeKeychainFingerprintStore? @TaskLocal static var taskPendingCacheClearStoreOverride: ClaudeOAuthPendingCacheClearStore? @TaskLocal static var taskImplicitPendingCacheClearStoreOverride: ClaudeOAuthPendingCacheClearStore? + @TaskLocal static var taskDirectKeychainReadConsentRevocationMarkerStoreOverride: + DirectKeychainReadConsentRevocationMarkerStore? @TaskLocal static var taskUseEnvironmentCredentialsURLForTesting = false typealias OAuthCacheOperation = KeychainCacheStore.Operation @@ -156,6 +158,28 @@ extension ClaudeOAuthCredentialsStore { var profileIdentifier: String? } + final class DirectKeychainReadConsentRevocationMarkerStore: @unchecked Sendable { + private let lock = NSLock() + private var value: String? + + var marker: String? { + self.lock.withLock { self.value } + } + + func advance() { + self.lock.withLock { self.value = UUID().uuidString } + } + } + + static func withDirectKeychainReadConsentRevocationMarkerStoreForTesting( + _ store: DirectKeychainReadConsentRevocationMarkerStore, + operation: () throws -> T) rethrows -> T + { + try self.$taskDirectKeychainReadConsentRevocationMarkerStoreOverride.withValue(store) { + try operation() + } + } + static func withClaudeKeychainOverridesForTesting( data: Data?, fingerprint: ClaudeKeychainFingerprint?, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 438241de6f..1243f258fa 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -87,6 +87,12 @@ public enum ClaudeOAuthCredentialsStore { private static let claudeKeychainFingerprintLegacyKey = "ClaudeOAuthClaudeKeychainFingerprintV1" private static let pendingCodexBarOAuthKeychainCacheClearKey = "ClaudeOAuthPendingCodexBarOAuthKeychainCacheClearV1" + private static let directKeychainReadConsentRevocationMarkerKey = + "ClaudeOAuthDirectKeychainReadConsentRevocationMarkerV1" + private static var sharedDefaults: UserDefaults { + UserDefaults(suiteName: "com.steipete.codexbar") ?? .standard + } + private static let pendingCodexBarOAuthKeychainCacheClearStore: ClaudeOAuthPendingCacheClearStore = ClaudeOAuthPendingCacheClearUserDefaultsStore( // The cache service is shared by release/debug apps and their CLIs, so its tombstone is shared too. @@ -144,6 +150,8 @@ public enum ClaudeOAuthCredentialsStore { /// One-way ownership evidence for the Claude profile whose credentials path produced this cache. /// A missing value is a legacy entry. It can be migrated only to the historical default profile. let profileIdentifier: String? + /// Global consent epoch at creation time. A legacy missing value is generation zero. + let directKeychainReadConsentRevocationMarker: String? init( data: Data, @@ -151,7 +159,9 @@ public enum ClaudeOAuthCredentialsStore { owner: ClaudeOAuthCredentialOwner? = nil, historyOwnerIdentifier: String? = nil, profileIdentifier: String? = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( - environment: ProcessInfo.processInfo.environment)) + environment: ProcessInfo.processInfo.environment), + directKeychainReadConsentRevocationMarker: String? = ClaudeOAuthCredentialsStore + .currentDirectKeychainReadConsentRevocationMarker) { self.data = data self.storedAt = storedAt @@ -159,6 +169,7 @@ public enum ClaudeOAuthCredentialsStore { self.historyOwnerIdentifier = ClaudeOAuthCredentials.normalizedHistoryOwnerIdentifier( historyOwnerIdentifier) self.profileIdentifier = profileIdentifier + self.directKeychainReadConsentRevocationMarker = directKeychainReadConsentRevocationMarker } } @@ -1532,6 +1543,12 @@ public enum ClaudeOAuthCredentialsStore { credentials: credentials, environment: environment) } + + static func claudeKeychainCredentialMatchForTesting( + credentials: ClaudeOAuthCredentials) -> ClaudeKeychainCredentialMatch + { + self.claudeKeychainCredentialMatchWithoutPrompt(for: credentials) + } #endif /// Async version of load that handles expired tokens based on credential ownership. @@ -1861,6 +1878,7 @@ public enum ClaudeOAuthCredentialsStore { private static func newestClaudeKeychainCredentialEvidenceWithoutPrompt() -> ClaudeKeychainProbe { + guard self.keychainAccessAllowed else { return .unavailable } #if DEBUG if let store = self.taskClaudeKeychainOverrideStore { guard store.data != nil || store.fingerprint != nil else { return .value(nil) } @@ -1967,6 +1985,15 @@ public enum ClaudeOAuthCredentialsStore { Repository(context: self.currentCollaboratorContext()).invalidateCache(environment: environment) } + /// Retires every Claude CLI-owned cache written before consent was revoked. Profile cache keys are one-way + /// hashes, so an epoch guard is the only bounded way to cover previously used `CLAUDE_CONFIG_DIR` values. + public static func revokeDirectKeychainReadConsent( + environment: [String: String] = ProcessInfo.processInfo.environment) + { + self.advanceDirectKeychainReadConsentRevocationMarker() + self.invalidateCache(environment: environment) + } + /// Check if CodexBar has cached credentials (in memory or keychain cache) public static func hasCachedCredentials(environment: [String: String] = ProcessInfo.processInfo .environment) -> Bool @@ -2730,7 +2757,13 @@ public enum ClaudeOAuthCredentialsStore { let profileCacheKey = self.cacheKey(profileIdentifier: profileIdentifier) let loaded = KeychainCacheStore.load(key: profileCacheKey, as: CacheEntry.self) switch loaded { - case .found, .missing: + case let .found(entry) where self.cacheEntrySurvivesDirectKeychainReadConsentRevocation(entry): + break + case .found: + profilePending = !self.clearProfileCacheKeychain(profileIdentifier: profileIdentifier) + result = .missing + return + case .missing: break case .invalid, .temporarilyUnavailable: result = loaded @@ -2778,13 +2811,19 @@ public enum ClaudeOAuthCredentialsStore { result = legacyLoaded return } + guard self.cacheEntrySurvivesDirectKeychainReadConsentRevocation(entry) else { + legacyCleanupPending = !self.clearLegacyCacheKeychain() + result = .missing + return + } if self.legacyCacheEntry(entry, isAttributableTo: profileIdentifier) { let migrated = CacheEntry( data: entry.data, storedAt: entry.storedAt, owner: entry.owner, historyOwnerIdentifier: entry.historyOwnerIdentifier, - profileIdentifier: profileIdentifier) + profileIdentifier: profileIdentifier, + directKeychainReadConsentRevocationMarker: entry.directKeychainReadConsentRevocationMarker) guard KeychainCacheStore.storeResult(key: profileCacheKey, entry: migrated) else { self.log.warning("Claude OAuth legacy cache profile migration could not be persisted") result = .temporarilyUnavailable @@ -2799,6 +2838,36 @@ public enum ClaudeOAuthCredentialsStore { return result } + private static func cacheEntrySurvivesDirectKeychainReadConsentRevocation(_ entry: CacheEntry) -> Bool { + guard entry.owner == nil || entry.owner == .claudeCLI else { return true } + guard let marker = self.currentDirectKeychainReadConsentRevocationMarker else { return true } + return entry.directKeychainReadConsentRevocationMarker == marker + } + + private static var currentDirectKeychainReadConsentRevocationMarker: String? { + #if DEBUG + if let store = self.taskDirectKeychainReadConsentRevocationMarkerStoreOverride { + return store.marker + } + if KeychainTestSafety.shouldIsolateUserStateUnderTests() { + return nil + } + #endif + self.sharedDefaults.synchronize() + return self.sharedDefaults.string(forKey: self.directKeychainReadConsentRevocationMarkerKey) + } + + private static func advanceDirectKeychainReadConsentRevocationMarker() { + #if DEBUG + if let store = self.taskDirectKeychainReadConsentRevocationMarkerStoreOverride { + store.advance() + return + } + #endif + self.sharedDefaults.set(UUID().uuidString, forKey: self.directKeychainReadConsentRevocationMarkerKey) + self.sharedDefaults.synchronize() + } + private static func cacheKey(profileIdentifier: String) -> KeychainCacheStore.Key { KeychainCacheStore.Key( category: self.legacyCacheKey.category, @@ -2901,14 +2970,20 @@ public enum ClaudeOAuthCredentialsStore { if KeychainAccessGate.currentOverrideForTesting == true { return false } + if let consentOverride = ClaudeOAuthDirectKeychainReadConsent.taskOverrideForTesting { + return consentOverride + } if self.hasTaskKeychainTestingOverride { return true } #endif // Claude Code owns `Claude Code-credentials` and rewrites the item during token refreshes. That rewrite - // replaces its ACL, so any permission granted to CodexBar is inherently temporary and causes recurring - // macOS password dialogs. Production CodexBar therefore never reads the foreign item, with or without UI. - return false + // replaces its ACL, so any permission granted to CodexBar is inherently temporary and can cause recurring + // macOS password dialogs. Production CodexBar therefore reads the foreign item only after the user + // explicitly opted in (#2634); without consent every direct-read path stays closed, including the + // freshness sync and delegated-refresh verification that route through this same gate. + guard !KeychainAccessGate.isDisabled else { return false } + return ClaudeOAuthDirectKeychainReadConsent.isGranted() } #if DEBUG diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift new file mode 100644 index 0000000000..6011489140 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Explicit, durable user consent for reading Claude Code's own Keychain item (`Claude Code-credentials`). +/// +/// CodexBar 0.47.0 stopped reading that foreign item entirely because Claude Code rewrites its ACL on +/// every token refresh, which makes any granted permission temporary and causes recurring macOS password +/// dialogs (#2380). That hard stop also removed every recovery path on Claude Code 2.1.x, which stores +/// credentials Keychain-only (#2634). This consent restores the pre-0.47 direct read as an informed opt-in: +/// default OFF, never enabled silently on upgrade, and revocable at any time from Claude provider settings. +/// +/// The stored flag feeds `ClaudeOAuthCredentialsStore.keychainAccessAllowed` — the single choke point for +/// the direct read, the pre-emptive freshness sync, and delegated-refresh success verification — so all +/// three paths open and close together. +public enum ClaudeOAuthDirectKeychainReadConsent { + /// Written by the app's SettingsStore; read here through the shared application defaults domain so the + /// CLI and helper processes resolve the same consent the app persisted. + public static let userDefaultsKey = "claudeOAuthDirectKeychainReadAllowed" + + #if DEBUG + @TaskLocal private static var taskOverride: Bool? + + static var taskOverrideForTesting: Bool? { + self.taskOverride + } + #endif + + public static func isGranted(userDefaults: UserDefaults? = nil) -> Bool { + #if DEBUG + if let taskOverride { + return taskOverride + } + // Unit tests must not inherit the developer's persisted consent. Tests that exercise consent use a + // task or UserDefaults override explicitly. + if userDefaults == nil, KeychainTestSafety.shouldIsolateUserStateUnderTests() { + return false + } + #endif + let defaults = userDefaults ?? ClaudeOAuthKeychainPromptPreference.applicationUserDefaults + return defaults.bool(forKey: self.userDefaultsKey) + } + + #if DEBUG + public static func withTaskOverrideForTesting( + _ granted: Bool?, + operation: () throws -> T) rethrows -> T + { + try self.$taskOverride.withValue(granted) { + try operation() + } + } + + public static func withTaskOverrideForTesting( + _ granted: Bool?, + isolation _: isolated (any Actor)? = #isolation, + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskOverride.withValue(granted) { + try await operation() + } + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 4630a5f016..c150c5fc9b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -699,6 +699,11 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { guard !Task.isCancelled, !ClaudeOAuthFetchError.isCancellation(error) else { return false } + // The unreadable terminal state (#2634): Claude Code's Keychain item is closed to us (no consent) + // and no credentials file exists. OAuth cannot recover, so hand off to the owner CLI usage fallback. + if context.runtime == .app, error is ClaudeOAuthUnreadableCredentialsError { + return true + } if context.runtime == .app, context.sourceMode == .oauth, let credentialsError = error as? ClaudeOAuthCredentialsError @@ -715,7 +720,10 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { return context.runtime == .app && context.sourceMode == .auto } - fileprivate static func snapshot(from usage: ClaudeUsageSnapshot) -> UsageSnapshot { + fileprivate static func snapshot( + from usage: ClaudeUsageSnapshot, + dataConfidence: UsageDataConfidence = .unknown) -> UsageSnapshot + { let identity = ProviderIdentitySnapshot( providerID: .claude, accountEmail: usage.accountEmail, @@ -729,11 +737,15 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { extraRateWindows: usage.extraRateWindows.isEmpty ? nil : usage.extraRateWindows, providerCost: usage.providerCost, updatedAt: usage.updatedAt, - identity: identity) + identity: identity, + dataConfidence: dataConfidence) } - static func _snapshotForTesting(from usage: ClaudeUsageSnapshot) -> UsageSnapshot { - self.snapshot(from: usage) + static func _snapshotForTesting( + from usage: ClaudeUsageSnapshot, + dataConfidence: UsageDataConfidence = .unknown) -> UsageSnapshot + { + self.snapshot(from: usage, dataConfidence: dataConfidence) } } @@ -1017,7 +1029,9 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { ClaudeCLIBackgroundAvailability.establish(backgroundAvailabilityMarker) } return self.makeResult( - usage: ClaudeOAuthFetchStrategy.snapshot(from: usage), + // The PTY /usage panel exposes rendered percentages only, so CLI-sourced data carries an + // explicit degraded-fidelity marker that the card surfaces as "via Claude CLI". + usage: ClaudeOAuthFetchStrategy.snapshot(from: usage, dataConfidence: .percentOnly), sourceLabel: "claude") } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher+DelegatedRefreshMessages.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher+DelegatedRefreshMessages.swift index 69b0b4178f..3269d5bb50 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher+DelegatedRefreshMessages.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher+DelegatedRefreshMessages.swift @@ -1,5 +1,29 @@ import Foundation +/// Terminal state from #2634/#2650: the delegated Claude CLI touch completed cleanly, but Claude Code's +/// Keychain item is not readable (no direct-read consent) and no credentials file exists for the profile, +/// so retrying cannot restore OAuth usage. Typed so the fetch pipeline can fall back to reading usage from +/// the Claude CLI itself instead of trapping the user on an unrecoverable OAuth error. +public struct ClaudeOAuthUnreadableCredentialsError: LocalizedError, Equatable, Sendable { + public let message: String + + public init(message: String) { + self.message = message + } + + public var errorDescription: String? { + self.message + } + + /// The stable lead-in used to recognize this state from a persisted error string (e.g. for the + /// provider-card call to action after the error crossed an untyped boundary). + public static let descriptionPrefix = "Claude OAuth credentials expired and CodexBar cannot read them back" + + public static func matches(description: String?) -> Bool { + description?.hasPrefix(self.descriptionPrefix) ?? false + } +} + /// Split out of `ClaudeUsageFetcher.swift` to keep that file within the file-length limit. extension ClaudeUsageFetcher { static func delegatedRefreshOutcomeLabel( @@ -19,6 +43,23 @@ extension ClaudeUsageFetcher { } } + /// The unreadable terminal state comes back as the typed `ClaudeOAuthUnreadableCredentialsError` so the + /// pipeline can hand off to the Claude CLI usage fallback; everything else stays a plain OAuth failure. + static func delegatedRefreshFailureError( + for result: ClaudeOAuthDelegatedRefreshCoordinator.AttemptResult, + retryError: Error) -> Error + { + let message = self.delegatedRefreshFailureMessage(for: result, retryError: retryError) + var isRateLimited = false + if let oauthError = retryError as? ClaudeOAuthFetchError, case .rateLimited = oauthError { + isRateLimited = true + } + if result.isUnreadableAfterRefresh, !isRateLimited { + return ClaudeOAuthUnreadableCredentialsError(message: message) + } + return ClaudeUsageError.oauthFailed(message) + } + static func delegatedRefreshFailureMessage( for result: ClaudeOAuthDelegatedRefreshCoordinator.AttemptResult, retryError: Error) -> String @@ -31,10 +72,11 @@ extension ClaudeUsageFetcher { if result.isUnreadableAfterRefresh { // Not "run `claude login`, then retry": that refreshes Claude Code's own Keychain item, which this - // build never reads, so the same expired cache comes back. - return "Claude OAuth credentials expired and CodexBar cannot read them back. Claude Code owns the " - + "Keychain item and no credentials file is present for this profile, so refreshing will not " - + "restore usage. Switch Claude Usage source to Web/CLI." + // build does not read without consent, so the same expired cache comes back. + return ClaudeOAuthUnreadableCredentialsError.descriptionPrefix + + ": Claude Code keeps them only in its own Keychain item, which CodexBar reads only with your " + + "permission. Enable \u{201C}Allow reading Claude Code credentials\u{201D} in Claude settings to " + + "restore OAuth usage, or CodexBar uses the Claude CLI when it is available." } switch result.outcome { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 05b8d5a61a..ad5199f766 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -517,10 +517,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { error: error, oauthKeychainPromptCooldownEnabled: self.fetcher.oauthKeychainPromptCooldownEnabled, delegatedOutcome: delegatedOutcome)) - throw ClaudeUsageError.oauthFailed( - ClaudeUsageFetcher.delegatedRefreshFailureMessage( - for: delegatedResult, - retryError: error)) + throw ClaudeUsageFetcher.delegatedRefreshFailureError( + for: delegatedResult, + retryError: error) } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift index 34e90f0ba7..f219358755 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -192,15 +192,19 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { persistentRefHash: "matching-keychain-item") let recordAfterCLIStorageAppears = try ClaudeOAuthCredentialsStore - .withClaudeKeychainOverridesForTesting( - data: keychainData, - fingerprint: keychainFingerprint) - { - try ClaudeOAuthCredentialsStore.loadRecord( - environment: environment, - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true, - allowClaudeKeychainRepairWithoutPrompt: false) + .withKeychainAccessOverrideForTesting(false) { + try ClaudeOAuthDirectKeychainReadConsent.withTaskOverrideForTesting(true) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: keychainFingerprint) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: environment, + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + } + } } #expect(recordAfterCLIStorageAppears.credentials.accessToken == "fresh-codexbar-token") diff --git a/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift b/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift new file mode 100644 index 0000000000..80a7722025 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift @@ -0,0 +1,316 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +/// Coverage for the #2634 consent gate: reading Claude Code's own Keychain item is allowed only after an +/// explicit opt-in, and every production read path (direct read, freshness sync, delegated-refresh +/// verification) resolves through the single `keychainAccessAllowed` choke point. +@Suite(.serialized) +struct ClaudeOAuthDirectKeychainReadConsentTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext( + runtime: CodexBarCore.ProviderRuntime, + sourceMode: ProviderSourceMode) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: runtime, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func makeCredentialsData(accessToken: String) -> Data { + let expiresAt = Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000) + return Data(""" + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(expiresAt), + "scopes": ["user:profile"] + } + } + """.utf8) + } + + // MARK: - Consent choke point + + @Test + func `keychain access stays denied without consent even when the global gate is enabled`() { + KeychainAccessGate.withTaskOverrideForTesting(false) { + // Task-isolated consent default is OFF; no silent enable on upgrade. + #expect(ClaudeOAuthCredentialsStore.keychainAccessAllowed == false) + ClaudeOAuthDirectKeychainReadConsent.withTaskOverrideForTesting(false) { + #expect(ClaudeOAuthCredentialsStore.keychainAccessAllowed == false) + } + } + } + + @Test + func `explicit consent reopens the single keychain access choke point`() { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthDirectKeychainReadConsent.withTaskOverrideForTesting(true) { + #expect(ClaudeOAuthCredentialsStore.keychainAccessAllowed == true) + } + } + } + + @Test + func `disabling the global keychain gate wins over granted consent`() { + KeychainAccessGate.withTaskOverrideForTesting(true) { + ClaudeOAuthDirectKeychainReadConsent.withTaskOverrideForTesting(true) { + #expect(ClaudeOAuthCredentialsStore.keychainAccessAllowed == false) + } + } + } + + // MARK: - Consent storage + + @Test + func `stored consent defaults to off and honors an explicit opt in`() throws { + let suiteName = "codexbar-consent-tests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(ClaudeOAuthDirectKeychainReadConsent.isGranted(userDefaults: defaults) == false) + defaults.set(true, forKey: ClaudeOAuthDirectKeychainReadConsent.userDefaultsKey) + #expect(ClaudeOAuthDirectKeychainReadConsent.isGranted(userDefaults: defaults) == true) + defaults.set(false, forKey: ClaudeOAuthDirectKeychainReadConsent.userDefaultsKey) + #expect(ClaudeOAuthDirectKeychainReadConsent.isGranted(userDefaults: defaults) == false) + } + + // MARK: - Unreadable terminal state typing + + @Test + func `unreadable refresh result surfaces as the typed unreadable credentials error`() { + let result = ClaudeOAuthDelegatedRefreshCoordinator.AttemptResult( + .attemptedFailed("No readable Claude credential source after the Claude CLI touch."), + isUnreadableAfterRefresh: true) + let error = ClaudeUsageFetcher.delegatedRefreshFailureError( + for: result, + retryError: ClaudeOAuthCredentialsError.notFound) + let unreadable = error as? ClaudeOAuthUnreadableCredentialsError + #expect(unreadable != nil) + #expect(ClaudeOAuthUnreadableCredentialsError.matches(description: unreadable?.errorDescription)) + #expect(unreadable?.message.contains("Allow reading Claude Code credentials") == true) + } + + @Test + func `rate limited retries stay plain oauth failures even when unreadable`() { + let result = ClaudeOAuthDelegatedRefreshCoordinator.AttemptResult( + .attemptedFailed("touch failed"), + isUnreadableAfterRefresh: true) + let error = ClaudeUsageFetcher.delegatedRefreshFailureError( + for: result, + retryError: ClaudeOAuthFetchError.rateLimited(retryAfter: nil)) + #expect(error is ClaudeUsageError) + #expect(!(error is ClaudeOAuthUnreadableCredentialsError)) + } + + @Test + func `readable refresh failures stay plain oauth failures`() { + let result = ClaudeOAuthDelegatedRefreshCoordinator.AttemptResult( + .attemptedFailed("touch failed"), + isUnreadableAfterRefresh: false) + let error = ClaudeUsageFetcher.delegatedRefreshFailureError( + for: result, + retryError: ClaudeOAuthCredentialsError.notFound) + #expect(error is ClaudeUsageError) + } + + // MARK: - CLI usage fallback routing + + @Test + func `unreadable oauth error falls back to the owner cli step for explicit oauth and auto`() { + let strategy = ClaudeOAuthFetchStrategy() + let error = ClaudeOAuthUnreadableCredentialsError(message: "unreadable") + #expect(strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .oauth))) + #expect(strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .auto))) + #expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .cli, sourceMode: .oauth))) + } + + // MARK: - Degraded fidelity marker + + @Test + func `cli scraped usage carries the percent only confidence marker`() { + let usage = ClaudeUsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + opus: nil, + updatedAt: Date(), + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil, + rawText: nil) + let degraded = ClaudeOAuthFetchStrategy._snapshotForTesting(from: usage, dataConfidence: .percentOnly) + #expect(degraded.dataConfidence == .percentOnly) + let card = UsageMenuCardView.Model.make(UsageMenuCardView.Model.Input( + provider: .claude, + metadata: ProviderDescriptorRegistry.descriptor(for: .claude).metadata, + snapshot: degraded, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date())) + #expect(card.usageNotes == [L("Usage via Claude CLI (limited detail)")]) + let oauth = ClaudeOAuthFetchStrategy._snapshotForTesting(from: usage) + #expect(oauth.dataConfidence == .unknown) + } + + // MARK: - Consent revocation invalidates cached credentials + + @Test + @MainActor + func `revoking consent drops codexbar cached claude credentials and reroutes to the cli fallback`() throws { + let suite = "codexbar-consent-revocation-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let memory = ClaudeOAuthCredentialsStore.MemoryCacheStore() + ClaudeOAuthCredentialsStore.$taskMemoryCacheStoreOverride.withValue(memory) { + // Consent on: a credential read from Claude Code's Keychain lands in CodexBar's caches. + settings.claudeOAuthDirectKeychainReadAllowed = true + memory.record = ClaudeOAuthCredentialRecord( + credentials: ClaudeOAuthCredentials( + accessToken: "cached-from-claude-keychain", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil), + owner: .claudeCLI, + source: .claudeKeychain) + memory.timestamp = Date() + memory.profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:]) + #expect(memory.record != nil) + + // Consent off: the cached copy must not outlive the permission that obtained it. + settings.claudeOAuthDirectKeychainReadAllowed = false + #expect(memory.record == nil) + #expect(settings.claudeOAuthDirectKeychainReadAllowed == false) + + // The direct-read gate is closed again, and with no readable credential the explicit + // OAuth route hands off to the owner CLI usage fallback instead of reusing stale caches. + ClaudeOAuthDirectKeychainReadConsent.withTaskOverrideForTesting(false) { + #expect(ClaudeOAuthCredentialsStore.keychainAccessAllowed == false) + } + #expect(ClaudeOAuthFetchStrategy().shouldFallback( + on: ClaudeOAuthCredentialsError.notFound, + context: self.makeContext(runtime: .app, sourceMode: .oauth))) + } + } + + @Test + @MainActor + func `revoking consent retires cached credentials for every claude config profile`() throws { + let suite = "codexbar-consent-multi-profile-revocation-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let service = "com.steipete.codexbar.cache.consent-tests.\(UUID().uuidString)" + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + let revocationStore = ClaudeOAuthCredentialsStore.DirectKeychainReadConsentRevocationMarkerStore() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-consent-profiles-\(UUID().uuidString)", isDirectory: true) + let environmentA = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("a").path] + let environmentB = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("b").path] + + KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + ClaudeOAuthCredentialsStore + .withDirectKeychainReadConsentRevocationMarkerStoreForTesting(revocationStore) { + ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + settings.claudeOAuthDirectKeychainReadAllowed = true + let profileA = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environmentA) + let profileB = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environmentB) + let keyA = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: profileA) + let keyB = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: profileB) + KeychainCacheStore.store( + key: keyA, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-a-token"), + storedAt: Date(), + owner: .claudeCLI, + profileIdentifier: profileA)) + KeychainCacheStore.store( + key: keyB, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-token"), + storedAt: Date(), + owner: .claudeCLI, + profileIdentifier: profileB)) + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environmentA)) + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environmentB)) + + settings.claudeOAuthDirectKeychainReadAllowed = false + + #expect(!ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environmentA)) + #expect(!ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environmentB)) + guard case .missing = KeychainCacheStore.load( + key: keyA, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + else { + Issue.record("Expected profile A's pre-revocation cache to be retired") + return + } + guard case .missing = KeychainCacheStore.load( + key: keyB, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + else { + Issue.record("Expected profile B's pre-revocation cache to be retired") + return + } + } + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift index fb22289643..6fe7e50486 100644 --- a/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift @@ -137,6 +137,60 @@ struct ClaudeOAuthRefreshChainOwnershipTests { } } + @Test + func `consent off keeps a keychain only signed in profile CLI owned`() throws { + let profile = try self.makeProfile(accountUuid: "keychain-only-profile") + defer { try? FileManager.default.removeItem(at: profile.directory) } + let keychain = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore() + + let owner = KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthDirectKeychainReadConsent.withTaskOverrideForTesting(false) { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(keychain) { + #expect(!ClaudeOAuthCredentialsStore.keychainAccessAllowed) + #expect(ClaudeAccountProfile.configOwnershipEvidence(environment: profile.environment) + == .signedIn(accountUuid: "keychain-only-profile")) + #expect(ClaudeOAuthCredentialsStore.claudeKeychainCredentialMatchForTesting( + credentials: self.makeCredentials()) == .unavailable) + return ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + } + } + } + + #expect(owner == .claudeCLI) + } + + @Test + func `consent on restores verified keychain absence for a signed in profile`() throws { + let profile = try self.makeProfile(accountUuid: "consented-profile") + defer { try? FileManager.default.removeItem(at: profile.directory) } + let keychain = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore() + + let owner = KeychainAccessGate.withTaskOverrideForTesting(false) { + ClaudeOAuthDirectKeychainReadConsent.withTaskOverrideForTesting(true) { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(keychain) { + #expect(ClaudeOAuthCredentialsStore.keychainAccessAllowed) + #expect(ClaudeAccountProfile.configOwnershipEvidence(environment: profile.environment) + == .signedIn(accountUuid: "consented-profile")) + #expect(ClaudeOAuthCredentialsStore.claudeKeychainCredentialMatchForTesting( + credentials: self.makeCredentials()) == .absent) + return ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + } + } + } + + #expect(owner == .codexbar) + } + @Test func `unavailable probe without logged in config keeps codexbar ownership`() throws { let profile = try self.makeProfile(accountUuid: nil) @@ -158,9 +212,10 @@ struct ClaudeOAuthRefreshChainOwnershipTests { func `absent keychain item keeps codexbar ownership`() throws { let profile = try self.makeProfile(accountUuid: nil) defer { try? FileManager.default.removeItem(at: profile.directory) } + let keychain = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore() let owner = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(data: nil, fingerprint: nil) { + ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(keychain) { ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( .codexbar, credentials: self.makeCredentials(), diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index e334d1459b..a00abeba1c 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -884,13 +884,13 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact provider-owned construct passes a fixed identity to shared infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SettingsStore+MenuObservation.swift", - line: 94, + line: 95, anchor: "_ = self[providerConfig: .synthetic, field: .apiKey]", expectedProviderIDs: ["synthetic"], reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SettingsStore+MenuObservation.swift", - line: 113, + line: 114, anchor: "_ = self[providerConfig: .warp, field: .apiKey]", expectedProviderIDs: ["warp"], reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), @@ -1829,13 +1829,13 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", line: 245, anchor: "if input.provider == .mimo, input.snapshot != nil {", - expectedProviderIDs: ["mimo"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["mimo@0"], + expectedProviderIDs: ["claude", "mimo"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["mimo@0", "claude@4"], reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 444, + line: 449, anchor: "if input.provider == .factory, snapshot.tertiary != nil {", expectedProviderIDs: ["alibabatokenplan", "amp", "crof", "cursor", "doubao", "factory", "grok", "sub2api"], expectedReferenceCount: 10, @@ -1854,7 +1854,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 557, + line: 562, anchor: "case .minimax:", expectedProviderIDs: ["codex", "minimax", "poe"], expectedReferenceCount: 3, @@ -1862,7 +1862,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 758, + line: 763, anchor: "if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {", expectedProviderIDs: ["claude", "codex", "copilot"], expectedReferenceCount: 4, @@ -1870,7 +1870,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 783, + line: 788, anchor: "let resetText = input.provider == .sub2api && namedWindow.window.resetsAt == nil", expectedProviderIDs: ["doubao", "sub2api"], expectedReferenceCount: 3, @@ -1878,7 +1878,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 854, + line: 859, anchor: "if input.provider == .antigravity,", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, @@ -1886,7 +1886,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 888, + line: 893, anchor: "if provider == .claude, window.windowMinutes != 10080 {", expectedProviderIDs: ["antigravity", "claude", "codex"], expectedReferenceCount: 4, @@ -1894,7 +1894,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 920, + line: 925, anchor: "guard input.provider == .antigravity else { return nil }", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, @@ -2258,7 +2258,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 1020, + line: 1024, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8,