From ec037bee64be5672f20fd03740f6213a01eeea8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 5 Aug 2026 06:17:31 -0700 Subject: [PATCH 1/6] fix: restore Claude usage on 0.47+ via consented keychain read and CLI fallback (#2634) --- CHANGELOG.md | 1 + .../CodexBar/MenuCardView+ModelHelpers.swift | 5 + .../Claude/ClaudeProviderImplementation.swift | 31 ++++ Sources/CodexBar/SettingsStore+Defaults.swift | 15 ++ .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 4 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 9 +- ...ClaudeOAuthDirectKeychainReadConsent.swift | 58 +++++++ .../Claude/ClaudeProviderDescriptor.swift | 24 ++- ...sageFetcher+DelegatedRefreshMessages.swift | 50 +++++- .../Providers/Claude/ClaudeUsageFetcher.swift | 7 +- ...eOAuthDirectKeychainReadConsentTests.swift | 153 ++++++++++++++++++ 13 files changed, 343 insertions(+), 16 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift create mode 100644 Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fbbb0bef46..0e4705d467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Codex: define Fast cost as estimated API Fast USD, resolve it models.dev-first with model-specific API ratios, and refresh GPT-5.6 Terra/Luna fallback rates (refs #2175). Thanks @iam-brain! ### Fixed +- 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! - Command Code: parse and display 5-hour and weekly rolling limits alongside monthly credits and reset times (#2466). Thanks @derekszen! - OpenCode Go: include Zen balance in CLI usage reads without waiting beyond five seconds (#2583). Thanks @Yuxin-Qiao! - Usage & Spend: keep validated Codex totals visible while the local scanner catches up, with refresh indicators in the dashboard and menu cost rows (#2397). Thanks @hhh2210! diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 07e4cea340..be15abf1dc 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -244,6 +244,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..318882fe74 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -613,6 +613,21 @@ 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 { + 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"]) + 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 b4659773cd..7646eb2068 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 6ee4934e72..aba17eab7a 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -491,6 +491,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 @@ -604,6 +607,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.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 21fb9cec0b..8c282d9d2e 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -2865,9 +2865,12 @@ public enum ClaudeOAuthCredentialsStore { } #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..5abb6b5d55 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift @@ -0,0 +1,58 @@ +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? + #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 8d205271c4..95b1d3efa6 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -522,6 +522,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 @@ -538,7 +543,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, @@ -552,11 +560,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) } } @@ -840,7 +852,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 4009c12b5b..4c1152216c 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/ClaudeOAuthDirectKeychainReadConsentTests.swift b/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift new file mode 100644 index 0000000000..3c787f34fb --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift @@ -0,0 +1,153 @@ +import Foundation +import Testing +@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: 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)) + } + + // 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 oauth = ClaudeOAuthFetchStrategy._snapshotForTesting(from: usage) + #expect(oauth.dataConfidence == .unknown) + } +} From 8ba7af523d507f71d7ac85eeba5908a4fe8ff74c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 6 Aug 2026 11:14:13 -0700 Subject: [PATCH 2/6] fix: invalidate CodexBar's Claude credential caches when direct-read consent is revoked --- Sources/CodexBar/SettingsStore+Defaults.swift | 9 ++++ ...eOAuthDirectKeychainReadConsentTests.swift | 54 ++++++++++++++++++- .../ProviderArchitectureGatekeeperTests.swift | 26 ++++----- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 318882fe74..485650e5ba 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -619,11 +619,20 @@ extension SettingsStore { 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. Drop them (CodexBar-owned state + // only — Claude Code's item is untouched) so the next load takes the consent-gated path and + // routes to the Claude CLI fallback. + ClaudeOAuthCredentialsStore.invalidateCache() + } self.noteBackgroundWorkSettingsChanged() } } diff --git a/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift b/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift index 3c787f34fb..7e719c5020 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDirectKeychainReadConsentTests.swift @@ -1,5 +1,6 @@ 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 @@ -21,7 +22,10 @@ struct ClaudeOAuthDirectKeychainReadConsentTests { } } - private func makeContext(runtime: ProviderRuntime, sourceMode: ProviderSourceMode) -> ProviderFetchContext { + private func makeContext( + runtime: CodexBarCore.ProviderRuntime, + sourceMode: ProviderSourceMode) -> ProviderFetchContext + { ProviderFetchContext( runtime: runtime, sourceMode: sourceMode, @@ -150,4 +154,52 @@ struct ClaudeOAuthDirectKeychainReadConsentTests { 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() + try 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))) + } + } } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index c9b72965a8..b6786c4a13 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."), @@ -1853,13 +1853,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, @@ -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: 557, + line: 562, anchor: "case .minimax:", expectedProviderIDs: ["codex", "minimax", "poe"], expectedReferenceCount: 3, @@ -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: 758, + line: 763, anchor: "if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {", expectedProviderIDs: ["claude", "codex", "copilot"], 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: 783, + line: 788, anchor: "let resetText = input.provider == .sub2api && namedWindow.window.resetsAt == nil", expectedProviderIDs: ["doubao", "sub2api"], expectedReferenceCount: 3, @@ -1902,7 +1902,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, @@ -1910,7 +1910,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, @@ -1918,7 +1918,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, @@ -2282,7 +2282,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, From bdea8222ad0b29b4d7b2f35e958289edf1dca3df Mon Sep 17 00:00:00 2001 From: avenoxai <189995891+avenoxai@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:49:13 +0300 Subject: [PATCH 3/6] Scope Claude OAuth terminal refresh block to the failed token lineage --- .../ClaudeOAuthRefreshFailureGate.swift | 29 ++++++-- .../ClaudeOAuthRefreshFailureGateTests.swift | 74 +++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift index 82d39f00b4..da43fc157b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift @@ -35,6 +35,7 @@ public enum ClaudeOAuthRefreshFailureGate { var fingerprintAtFailure: AuthFingerprint? var lastCredentialsRecheckAt: Date? var terminalReason: String? + var failedRefreshTokenHash: String? } private struct LockedState { @@ -47,6 +48,7 @@ public enum ClaudeOAuthRefreshFailureGate { private static let fingerprintKey = "claudeOAuthRefreshBackoffFingerprintV2" private static let terminalBlockedKey = "claudeOAuthRefreshTerminalBlockedV1" private static let terminalReasonKey = "claudeOAuthRefreshTerminalReasonV1" + private static let terminalTokenHashKey = "claudeOAuthRefreshTerminalTokenHashV1" private static let transientBlockedUntilKey = "claudeOAuthRefreshTransientBlockedUntilV1" private static let transientFailureCountKey = "claudeOAuthRefreshTransientFailureCountV1" private static let profileKeySeparator = ".profile." @@ -139,7 +141,8 @@ public enum ClaudeOAuthRefreshFailureGate { public static func shouldAttempt( environment: [String: String] = ProcessInfo.processInfo.environment, - now: Date = Date()) -> Bool + now: Date = Date(), + refreshTokenHash: String? = nil) -> Bool { #if DEBUG if let override = self.shouldAttemptOverride { @@ -158,6 +161,9 @@ public enum ClaudeOAuthRefreshFailureGate { } if state.isTerminalBlocked { + if let refreshTokenHash, refreshTokenHash != state.failedRefreshTokenHash { + return true + } guard self.shouldRecheckCredentials(now: now, state: state) else { return false } state.lastCredentialsRecheckAt = now @@ -234,7 +240,8 @@ public enum ClaudeOAuthRefreshFailureGate { public static func recordTerminalAuthFailure( environment: [String: String] = ProcessInfo.processInfo.environment, - now: Date = Date()) + now: Date = Date(), + refreshTokenHash: String? = nil) { self.withState(environment: environment) { state, profileIdentifier in _ = self.loadIfNeeded( @@ -245,6 +252,7 @@ public enum ClaudeOAuthRefreshFailureGate { state.terminalFailureCount += 1 state.isTerminalBlocked = true state.terminalReason = "invalid_grant" + state.failedRefreshTokenHash = refreshTokenHash state.fingerprintAtFailure = self.currentFingerprint(environment: environment) ?? self.unknownFingerprint state.lastCredentialsRecheckAt = now self.clearTransientState(&state) @@ -283,7 +291,7 @@ public enum ClaudeOAuthRefreshFailureGate { now: Date = Date()) { // Legacy shim: treat as terminal auth failure. - self.recordTerminalAuthFailure(environment: environment, now: now) + self.recordTerminalAuthFailure(environment: environment, now: now, refreshTokenHash: nil) } public static func recordSuccess( @@ -335,6 +343,7 @@ public enum ClaudeOAuthRefreshFailureGate { self.fingerprintKey, self.terminalBlockedKey, self.terminalReasonKey, + self.terminalTokenHashKey, self.transientBlockedUntilKey, self.transientFailureCountKey, ] @@ -382,6 +391,7 @@ public enum ClaudeOAuthRefreshFailureGate { state.transientFailureCount = defaults.integer(forKey: storageKey(self.transientFailureCountKey)) state.isTerminalBlocked = false state.terminalReason = nil + state.failedRefreshTokenHash = nil state.transientBlockedUntil = nil state.fingerprintAtFailure = nil @@ -401,6 +411,7 @@ public enum ClaudeOAuthRefreshFailureGate { if defaults.object(forKey: storageKey(self.terminalBlockedKey)) != nil { state.isTerminalBlocked = defaults.bool(forKey: storageKey(self.terminalBlockedKey)) state.terminalReason = defaults.string(forKey: storageKey(self.terminalReasonKey)) + state.failedRefreshTokenHash = defaults.string(forKey: storageKey(self.terminalTokenHashKey)) if legacyBlockedUntil != nil { didMutate = true } @@ -452,6 +463,11 @@ public enum ClaudeOAuthRefreshFailureGate { } else { defaults.removeObject(forKey: key(self.terminalReasonKey)) } + if let failedRefreshTokenHash = state.failedRefreshTokenHash { + defaults.set(failedRefreshTokenHash, forKey: key(self.terminalTokenHashKey)) + } else { + defaults.removeObject(forKey: key(self.terminalTokenHashKey)) + } defaults.set(state.transientFailureCount, forKey: key(self.transientFailureCountKey)) if let blockedUntil = state.transientBlockedUntil { @@ -489,6 +505,7 @@ public enum ClaudeOAuthRefreshFailureGate { state.terminalFailureCount = 0 state.isTerminalBlocked = false state.terminalReason = nil + state.failedRefreshTokenHash = nil } private static func clearTransientState(_ state: inout State) { @@ -512,7 +529,8 @@ public enum ClaudeOAuthRefreshFailureGate { public static func shouldAttempt( environment _: [String: String] = ProcessInfo.processInfo.environment, - now _: Date = Date()) -> Bool + now _: Date = Date(), + refreshTokenHash _: String? = nil) -> Bool { true } @@ -526,7 +544,8 @@ public enum ClaudeOAuthRefreshFailureGate { public static func recordTerminalAuthFailure( environment _: [String: String] = ProcessInfo.processInfo.environment, - now _: Date = Date()) {} + now _: Date = Date(), + refreshTokenHash _: String? = nil) {} public static func recordTransientFailure( environment _: [String: String] = ProcessInfo.processInfo.environment, diff --git a/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift index 5dc8225b27..bf195f6e44 100644 --- a/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift @@ -9,6 +9,7 @@ struct ClaudeOAuthRefreshFailureGateTests { private let legacyFailureCountKey = "claudeOAuthRefreshBackoffFailureCountV1" private let legacyFingerprintKey = "claudeOAuthRefreshBackoffFingerprintV2" private let terminalBlockedKey = "claudeOAuthRefreshTerminalBlockedV1" + private let terminalTokenHashKey = "claudeOAuthRefreshTerminalTokenHashV1" private let transientBlockedUntilKey = "claudeOAuthRefreshTransientBlockedUntilV1" private let transientFailureCountKey = "claudeOAuthRefreshTransientFailureCountV1" @@ -313,6 +314,79 @@ struct ClaudeOAuthRefreshFailureGateTests { } } + @Test + func `terminal block is scoped to the failed refresh token lineage`() { + ClaudeOAuthRefreshFailureGate.resetForTesting() + defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 55000) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + ClaudeOAuthRefreshFailureGate.AuthFingerprint(keychain: nil, credentialsFile: nil) + } operation: { + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure( + now: start, + refreshTokenHash: "hash-h1") + + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(1), + refreshTokenHash: "hash-h1")) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(1), + refreshTokenHash: "hash-h2")) + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(1), + refreshTokenHash: nil)) + } + } + + @Test + func `legacy terminal block allows a new lineage then relatches`() { + ClaudeOAuthRefreshFailureGate.resetForTesting() + defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } + + UserDefaults.standard.set(true, forKey: self.profileKey(self.terminalBlockedKey)) + UserDefaults.standard.set(1, forKey: self.profileKey(self.legacyFailureCountKey)) + UserDefaults.standard.removeObject(forKey: self.profileKey(self.terminalTokenHashKey)) + ClaudeOAuthRefreshFailureGate.resetInMemoryStateForTesting() + + let start = Date(timeIntervalSince1970: 56000) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + ClaudeOAuthRefreshFailureGate.AuthFingerprint(keychain: nil, credentialsFile: nil) + } operation: { + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start, refreshTokenHash: "hash-h2")) + + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure( + now: start, + refreshTokenHash: "hash-h2") + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(1), + refreshTokenHash: "hash-h2")) + } + } + + @Test + func `terminal refresh token hash survives persistence round trip`() { + ClaudeOAuthRefreshFailureGate.resetForTesting() + defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 57000) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + ClaudeOAuthRefreshFailureGate.AuthFingerprint(keychain: nil, credentialsFile: nil) + } operation: { + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure( + now: start, + refreshTokenHash: "persisted-hash") + ClaudeOAuthRefreshFailureGate.resetInMemoryStateForTesting() + + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(1), + refreshTokenHash: "persisted-hash")) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(1), + refreshTokenHash: "replacement-hash")) + } + } + @Test func `transient backoff blocks until expiry then unblocks`() { ClaudeOAuthRefreshFailureGate.resetForTesting() From 89b7f8fd4aac31d185b7c02ca68468ae263659f4 Mon Sep 17 00:00:00 2001 From: avenoxai <189995891+avenoxai@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:49:13 +0300 Subject: [PATCH 4/6] Stop hijacking Claude CLI refresh chains when keychain evidence is unavailable --- CHANGELOG.md | 1 + .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 50 +++- ...entialsStoreCLIStorageOwnershipTests.swift | 23 +- .../ClaudeOAuthCredentialsStoreTests.swift | 7 +- ...laudeOAuthRefreshChainOwnershipTests.swift | 220 ++++++++++++++++++ 5 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cd0e2163a5..641ccdb573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.48.2 — Unreleased ### Fixed +- Claude: stop rotating Claude Code refresh tokens on keychain-only installs, and unblock refresh after the OAuth token lineage changes (#2745, refs #2689, #2634). - Menu: let long metric reset and pace details wrap to two lines instead of truncating, without clipping cached card heights (#2742). Thanks @Yuxin-Qiao! - Menu: let compact metric detail and reset rows wrap to a second line instead of truncating, so non-English locales keep the full pace and reset information (refs #2182). Thanks @Yuxin-Qiao! - Kimi: use official usage lane names and hide the Code 7-day row only when it duplicates the primary seven-day quota (matching percentage and reset) (#2741). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 63044b6686..71f19bd6fc 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -718,7 +718,7 @@ public enum ClaudeOAuthCredentialsStore { return record } - private func resolvedCacheOwner( + func resolvedCacheOwner( _ owner: ClaudeOAuthCredentialOwner, credentials: ClaudeOAuthCredentials, environment: [String: String]) -> ClaudeOAuthCredentialOwner @@ -728,8 +728,8 @@ public enum ClaudeOAuthCredentialsStore { matching: credentials, environment: environment) else { return owner } - // Claude Code rotates refresh tokens; selected-profile file evidence or a matching global - // Keychain credential proves that it owns this cache's refresh lifecycle. + // Claude Code rotates refresh tokens; evidence of CLI storage or a logged-in Claude Code config + // keeps the chain CLI-owned. Only positive absence lets CodexBar keep its mirror chain alive. return .claudeCLI } @@ -740,11 +740,22 @@ public enum ClaudeOAuthCredentialsStore { if ClaudeOAuthCredentialsStore.currentFileFingerprint(environment: environment) != nil { return true } - guard ClaudeOAuthKeychainPromptPreference.storedMode() != .never else { return false } - guard case .matched = ClaudeOAuthCredentialsStore.claudeKeychainCredentialMatchWithoutPrompt( - for: credentials) - else { return false } - return true + + let keychainMatch: ClaudeKeychainCredentialMatch = + if ClaudeOAuthKeychainPromptPreference.storedMode() == .never { + .unavailable + } else { + ClaudeOAuthCredentialsStore.claudeKeychainCredentialMatchWithoutPrompt(for: credentials) + } + + switch keychainMatch { + case .matched, .mismatch: + return true + case .absent: + return false + case .unavailable, .notApplicable: + return ClaudeAccountProfile.accountUuid(environment: environment) != nil + } } @discardableResult @@ -1388,7 +1399,11 @@ public enum ClaudeOAuthCredentialsStore { existingRateLimitTier: String?, existingSubscriptionType: String?) async throws -> ClaudeOAuthCredentials { - guard ClaudeOAuthRefreshFailureGate.shouldAttempt(environment: self.environment) else { + let refreshTokenHash = ClaudeOAuthCredentialsStore.sha256Hex(Data(refreshToken.utf8)) + guard ClaudeOAuthRefreshFailureGate.shouldAttempt( + environment: self.environment, + refreshTokenHash: refreshTokenHash) + else { let status = ClaudeOAuthRefreshFailureGate.currentBlockStatus(environment: self.environment) let message = switch status { case .terminal: @@ -1437,7 +1452,9 @@ public enum ClaudeOAuthCredentialsStore { switch disposition { case .terminalInvalidGrant: - ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(environment: self.environment) + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure( + environment: self.environment, + refreshTokenHash: refreshTokenHash) Repository(context: self.context).invalidateCache(environment: self.environment) let message = "HTTP \(response.statusCode) invalid_grant. " + ClaudeOAuthCredentialsStore.reauthenticateHint @@ -1493,6 +1510,19 @@ public enum ClaudeOAuthCredentialsStore { clearInvalidCache: clearInvalidCache) } + #if DEBUG + static func resolvedCacheOwnerForTesting( + _ owner: ClaudeOAuthCredentialOwner, + credentials: ClaudeOAuthCredentials, + environment: [String: String]) -> ClaudeOAuthCredentialOwner + { + Repository(context: self.currentCollaboratorContext()).resolvedCacheOwner( + owner, + credentials: credentials, + environment: environment) + } + #endif + /// Async version of load that handles expired tokens based on credential ownership. /// - Claude CLI-owned credentials delegate refresh to Claude CLI. /// - CodexBar-owned credentials refresh directly via token endpoint. diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift index dc967ef2b1..34e90f0ba7 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -100,6 +100,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let fileURL = tempDir.appendingPathComponent("credentials.json") + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: tempDir.path] try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { @@ -108,7 +109,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { let legacyCacheKey = KeychainCacheStore.Key.oauth(provider: .claude) let profileCacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( profileIdentifier: ClaudeOAuthCredentialsStore - .credentialsProfileIdentifier(environment: [:])) + .credentialsProfileIdentifier(environment: environment)) defer { KeychainCacheStore.clear(key: legacyCacheKey) KeychainCacheStore.clear(key: profileCacheKey) @@ -158,7 +159,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { }, operation: { try await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(true) { try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( - environment: [:], + environment: environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: true) } @@ -196,7 +197,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { fingerprint: keychainFingerprint) { try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], + environment: environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: true, allowClaudeKeychainRepairWithoutPrompt: false) @@ -227,18 +228,19 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let fileURL = tempDir.appendingPathComponent("credentials.json") + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: tempDir.path] try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( - environment: [:])) + environment: environment)) defer { KeychainCacheStore.clear(key: cacheKey) } ClaudeOAuthCredentialsStore.invalidateCache() let legacyCacheKey = KeychainCacheStore.Key.oauth(provider: .claude) let profileCacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( profileIdentifier: ClaudeOAuthCredentialsStore - .credentialsProfileIdentifier(environment: [:])) + .credentialsProfileIdentifier(environment: environment)) defer { KeychainCacheStore.clear(key: legacyCacheKey) KeychainCacheStore.clear(key: profileCacheKey) @@ -276,7 +278,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { }, operation: { try await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(true) { try await ClaudeOAuthCredentialsStore.loadRecordWithAutoRefresh( - environment: [:], + environment: environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: true) } @@ -301,7 +303,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { let restartedRecord = try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], + environment: environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: true, allowClaudeKeychainRepairWithoutPrompt: false) @@ -444,6 +446,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: tempDir.path] await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { @@ -466,7 +469,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { do { _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( - environment: [:], + environment: environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: true) Issue.record("Expected direct CodexBar refresh failure") @@ -488,7 +491,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } @Test - func `unrelated global Claude keychain item cannot re-own profile cache`() throws { + func `mismatched global Claude keychain item proves CLI storage ownership`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) @@ -547,7 +550,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } #expect(record.credentials.accessToken == "codexbar-cache") - #expect(record.owner == .codexbar) + #expect(record.owner == .claudeCLI) #expect(record.source == .cacheKeychain) } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 13ff13e0d7..30612a9f44 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -411,12 +411,15 @@ struct ClaudeOAuthCredentialsStoreTests { .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("credentials.json") + // Isolate from the developer's ambient Claude config: a logged-in ~/.claude.json would + // hand the refresh chain to the Claude CLI, and this test covers the CLI-absent path. + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: tempDir.path] await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { ClaudeOAuthCredentialsStore.invalidateCache() let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( - environment: [:])) + environment: environment)) defer { KeychainCacheStore.clear(key: cacheKey) } let expiredData = self.makeCredentialsData( @@ -433,7 +436,7 @@ struct ClaudeOAuthCredentialsStoreTests { await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { do { _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( - environment: [:], + environment: environment, allowKeychainPrompt: false, respectKeychainPromptCooldown: true) Issue.record("Expected refresh failure for CodexBar-owned direct refresh path") diff --git a/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift new file mode 100644 index 0000000000..957d8cfdd4 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift @@ -0,0 +1,220 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthRefreshChainOwnershipTests { + private func makeCredentials( + accessToken: String = "cached-access-token", + expiresAt: Date = Date(timeIntervalSinceNow: 3600)) -> ClaudeOAuthCredentials + { + ClaudeOAuthCredentials( + accessToken: accessToken, + refreshToken: "cached-refresh-token", + expiresAt: expiresAt, + scopes: ["user:profile"], + rateLimitTier: nil) + } + + private func credentialsData( + accessToken: String, + expiresAt: Date = Date(timeIntervalSinceNow: 3600)) -> Data + { + let expiresAtMilliseconds = Int(expiresAt.timeIntervalSince1970 * 1000) + return Data(""" + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "refreshToken": "cached-refresh-token", + "expiresAt": \(expiresAtMilliseconds), + "scopes": ["user:profile"] + } + } + """.utf8) + } + + private func makeProfile(accountUuid: String?, credentialsData: Data? = nil) throws + -> (directory: URL, environment: [String: String]) + { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + if let accountUuid { + let config = Data(""" + { + "oauthAccount": { + "accountUuid": "\(accountUuid)" + } + } + """.utf8) + try config.write(to: directory.appendingPathComponent(".claude.json")) + } + if let credentialsData { + try credentialsData.write(to: directory.appendingPathComponent(".credentials.json")) + } + return (directory, [ClaudeConfigPaths.configDirectoryEnvironmentKey: directory.path]) + } + + @Test + func `unavailable probe with logged in config delegates expired codexbar mirror`() async throws { + let profile = try self.makeProfile(accountUuid: "logged-in-profile") + defer { try? FileManager.default.removeItem(at: profile.directory) } + + let service = "com.steipete.codexbar.refresh-chain-ownership.\(UUID().uuidString)" + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + try await KeychainAccessGate.withTaskOverrideForTesting(true) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting( + pendingStore) + { + try await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(nil)) { + try await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try await ClaudeOAuthCredentialsStore + .withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + let profileIdentifier = ClaudeOAuthCredentialsStore + .credentialsProfileIdentifier(environment: profile.environment) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: profileIdentifier) + let expiredData = self.credentialsData( + accessToken: "expired-cached-access-token", + expiresAt: Date(timeIntervalSinceNow: -3600)) + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(), + owner: .codexbar, + profileIdentifier: profileIdentifier)) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: profile.environment, + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + #expect(record.owner == .claudeCLI) + + do { + _ = try await ClaudeOAuthCredentialsStore + .loadRecordWithAutoRefresh( + environment: profile.environment, + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + Issue.record("Expected Claude CLI delegated refresh") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record( + "Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + } + } + } + + @Test + func `unavailable probe without logged in config keeps codexbar ownership`() throws { + let profile = try self.makeProfile(accountUuid: nil) + defer { try? FileManager.default.removeItem(at: profile.directory) } + + let owner = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(nil)) { + ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + } + + #expect(owner == .codexbar) + } + + @Test + func `absent keychain item keeps codexbar ownership`() throws { + let profile = try self.makeProfile(accountUuid: nil) + defer { try? FileManager.default.removeItem(at: profile.directory) } + + let owner = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(data: nil, fingerprint: nil) { + ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + } + + #expect(owner == .codexbar) + } + + @Test + func `mismatched keychain item delegates codexbar ownership to CLI`() throws { + let profile = try self.makeProfile(accountUuid: nil) + defer { try? FileManager.default.removeItem(at: profile.directory) } + let keychainData = self.credentialsData(accessToken: "different-keychain-access-token") + let keychainFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 1, + persistentRefHash: "different-keychain-item") + + let owner = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: keychainFingerprint) + { + ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + } + + #expect(owner == .claudeCLI) + } + + @Test + func `credentials file keeps codexbar mirror CLI owned`() throws { + let profile = try self.makeProfile( + accountUuid: nil, + credentialsData: self.credentialsData(accessToken: "credentials-file-access-token")) + defer { try? FileManager.default.removeItem(at: profile.directory) } + + let owner = ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + + #expect(owner == .claudeCLI) + } + + @Test + func `never prompt mode falls back to logged in config`() throws { + let profile = try self.makeProfile(accountUuid: "never-mode-profile") + defer { try? FileManager.default.removeItem(at: profile.directory) } + + let owner = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + + #expect(owner == .claudeCLI) + } +} From 3ce97e6b065442a2f72e132267030eac60efa509 Mon Sep 17 00:00:00 2001 From: avenoxai <189995891+avenoxai@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:07:01 +0300 Subject: [PATCH 5/6] Keep unprovable chains CLI-owned; back off superseded lineages --- .../Claude/ClaudeAccountProfile.swift | 46 ++++++++- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 15 ++- .../ClaudeOAuthRefreshFailureGate.swift | 22 ++++- ...laudeOAuthRefreshChainOwnershipTests.swift | 71 ++++++++++++++ .../ClaudeOAuthRefreshFailureGateTests.swift | 94 +++++++++++++++++++ 5 files changed, 236 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeAccountProfile.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeAccountProfile.swift index 9e2c618f8b..5498655b99 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeAccountProfile.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeAccountProfile.swift @@ -16,16 +16,52 @@ public enum ClaudeAccountProfile { let oauthAccount: OAuthAccount? } + /// What Claude's plaintext account config can prove about CLI credential ownership. + /// `signedOut` requires positive evidence (no config file, or a cleanly parsed config with no + /// OAuth account); an unreadable, malformed, or unrecognized config is `indeterminate` because + /// it cannot establish that CLI storage is absent. + public enum ConfigOwnershipEvidence: Equatable, Sendable { + case signedIn(accountUuid: String) + case signedOut + case indeterminate + } + public static func accountUuid(environment: [String: String]) -> String? { + guard case let .signedIn(uuid) = self.configOwnershipEvidence(environment: environment) else { + return nil + } + return uuid + } + + public static func configOwnershipEvidence(environment: [String: String]) -> ConfigOwnershipEvidence { let url = ClaudeConfigPaths.accountConfigURL(environment: environment) - guard let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode(ClaudeConfigAccount.self, from: data), - let uuid = decoded.oauthAccount?.accountUuid?.trimmingCharacters(in: .whitespacesAndNewlines), + let data: Data + do { + data = try Data(contentsOf: url) + } catch let error as NSError + where error.domain == NSCocoaErrorDomain + && (error.code == NSFileReadNoSuchFileError || error.code == NSFileNoSuchFileError) + { + // A verifiably missing config is positive evidence that no signed-in CLI exists here. + return .signedOut + } catch { + // Present but unreadable (permissions, I/O): cannot prove CLI storage is absent. + return .indeterminate + } + guard let decoded = try? JSONDecoder().decode(ClaudeConfigAccount.self, from: data) else { + // Malformed or unrecognized schema: cannot prove CLI storage is absent. + return .indeterminate + } + guard let oauthAccount = decoded.oauthAccount else { + return .signedOut + } + guard let uuid = oauthAccount.accountUuid?.trimmingCharacters(in: .whitespacesAndNewlines), !uuid.isEmpty else { - return nil + // An OAuth account stanza without a usable identity is not proof of a signed-out CLI. + return .indeterminate } - return uuid + return .signedIn(accountUuid: uuid) } /// A process-local ownership key for Claude TUI reuse. Missing identity fails closed with a fresh scope. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 71f19bd6fc..438241de6f 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -754,7 +754,16 @@ public enum ClaudeOAuthCredentialsStore { case .absent: return false case .unavailable, .notApplicable: - return ClaudeAccountProfile.accountUuid(environment: environment) != nil + // The keychain cannot tell us anything, so Claude's plaintext config decides — + // and only positive proof of a signed-out CLI releases the chain to CodexBar. + // Indeterminate evidence (unreadable/malformed config) stays CLI-owned: never + // rotate a chain we cannot prove we own. + switch ClaudeAccountProfile.configOwnershipEvidence(environment: environment) { + case .signedIn, .indeterminate: + return true + case .signedOut: + return false + } } } @@ -1461,7 +1470,9 @@ public enum ClaudeOAuthCredentialsStore { throw ClaudeOAuthCredentialsError.refreshFailed( message) case .transientBackoff: - ClaudeOAuthRefreshFailureGate.recordTransientFailure(environment: self.environment) + ClaudeOAuthRefreshFailureGate.recordTransientFailure( + environment: self.environment, + refreshTokenHash: refreshTokenHash) let suffix = oauthError.map { " (\($0))" } ?? "" throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(response.statusCode)\(suffix)") } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift index da43fc157b..48b1a4bbd5 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthRefreshFailureGate.swift @@ -162,6 +162,11 @@ public enum ClaudeOAuthRefreshFailureGate { if state.isTerminalBlocked { if let refreshTokenHash, refreshTokenHash != state.failedRefreshTokenHash { + // The terminal block belongs to a superseded lineage, but an active transient + // backoff recorded for the new lineage still applies. + if let blockedUntil = state.transientBlockedUntil, blockedUntil > now { + return false + } return true } guard self.shouldRecheckCredentials(now: now, state: state) else { return false } @@ -262,7 +267,8 @@ public enum ClaudeOAuthRefreshFailureGate { public static func recordTransientFailure( environment: [String: String] = ProcessInfo.processInfo.environment, - now: Date = Date()) + now: Date = Date(), + refreshTokenHash: String? = nil) { self.withState(environment: environment) { state, profileIdentifier in _ = self.loadIfNeeded( @@ -271,9 +277,14 @@ public enum ClaudeOAuthRefreshFailureGate { environment: environment, now: now) - // Keep terminal blocking monotonic: once we know auth is rejected (e.g. invalid_grant), - // do not downgrade it to time-based backoff unless auth changes (fingerprint) or we record success. - guard !state.isTerminalBlocked else { return } + // Keep terminal blocking monotonic for the lineage that failed: once we know auth is + // rejected (e.g. invalid_grant), do not downgrade it to time-based backoff unless auth + // changes (fingerprint) or we record success. A transient failure on a different token + // lineage supersedes the dead lineage's terminal block instead of being dropped, so the + // new lineage transitions into transient backoff rather than retrying immediately. + if state.isTerminalBlocked { + guard let refreshTokenHash, refreshTokenHash != state.failedRefreshTokenHash else { return } + } self.clearTerminalState(&state) @@ -549,7 +560,8 @@ public enum ClaudeOAuthRefreshFailureGate { public static func recordTransientFailure( environment _: [String: String] = ProcessInfo.processInfo.environment, - now _: Date = Date()) {} + now _: Date = Date(), + refreshTokenHash _: String? = nil) {} public static func recordAuthFailure( environment _: [String: String] = ProcessInfo.processInfo.environment, diff --git a/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift index 957d8cfdd4..fb22289643 100644 --- a/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthRefreshChainOwnershipTests.swift @@ -55,6 +55,16 @@ struct ClaudeOAuthRefreshChainOwnershipTests { return (directory, [ClaudeConfigPaths.configDirectoryEnvironmentKey: directory.path]) } + private func makeProfileWithRawConfig(_ rawConfig: Data) throws + -> (directory: URL, environment: [String: String]) + { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try rawConfig.write(to: directory.appendingPathComponent(".claude.json")) + return (directory, [ClaudeConfigPaths.configDirectoryEnvironmentKey: directory.path]) + } + @Test func `unavailable probe with logged in config delegates expired codexbar mirror`() async throws { let profile = try self.makeProfile(accountUuid: "logged-in-profile") @@ -217,4 +227,65 @@ struct ClaudeOAuthRefreshChainOwnershipTests { #expect(owner == .claudeCLI) } + + @Test + func `malformed config keeps the mirror CLI owned`() throws { + let profile = try self.makeProfileWithRawConfig(Data("not json {".utf8)) + defer { try? FileManager.default.removeItem(at: profile.directory) } + + let owner = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(nil)) { + ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + } + + #expect(owner == .claudeCLI) + } + + @Test + func `oauth account without identity keeps the mirror CLI owned`() throws { + let profile = try self.makeProfileWithRawConfig(Data(""" + { + "oauthAccount": { + "accountUuid": " " + } + } + """.utf8)) + defer { try? FileManager.default.removeItem(at: profile.directory) } + + let owner = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(nil)) { + ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + } + + #expect(owner == .claudeCLI) + } + + @Test + func `cleanly signed out config releases the mirror to codexbar`() throws { + let profile = try self.makeProfileWithRawConfig(Data(""" + { + "installMethod": "brew" + } + """.utf8)) + defer { try? FileManager.default.removeItem(at: profile.directory) } + + let owner = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(nil)) { + ClaudeOAuthCredentialsStore.resolvedCacheOwnerForTesting( + .codexbar, + credentials: self.makeCredentials(), + environment: profile.environment) + } + } + + #expect(owner == .codexbar) + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift b/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift index bf195f6e44..d0eae700a8 100644 --- a/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthRefreshFailureGateTests.swift @@ -476,5 +476,99 @@ struct ClaudeOAuthRefreshFailureGateTests { #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true) } } + + @Test + func `legacy terminal block transitions into transient backoff on new lineage transient failure`() { + ClaudeOAuthRefreshFailureGate.resetForTesting() + defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 90000) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + ClaudeOAuthRefreshFailureGate.AuthFingerprint(keychain: nil, credentialsFile: nil) + } operation: { + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start) + + ClaudeOAuthRefreshFailureGate.recordTransientFailure( + now: start.addingTimeInterval(1), + refreshTokenHash: "hash-new-lineage") + + // The dead lineage's terminal block yields to transient backoff for the new lineage + // instead of dropping the failure and allowing an immediate retry loop. + guard case .transient = ClaudeOAuthRefreshFailureGate.currentBlockStatus( + now: start.addingTimeInterval(2)) + else { + Issue.record("Expected transient backoff after new-lineage transient failure") + return + } + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(2), + refreshTokenHash: "hash-new-lineage")) + #expect(ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(1 + 60 * 5 + 1), + refreshTokenHash: "hash-new-lineage")) + } + } + + @Test + func `terminal block for old lineage yields to new lineage transient backoff`() { + ClaudeOAuthRefreshFailureGate.resetForTesting() + defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 91000) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + ClaudeOAuthRefreshFailureGate.AuthFingerprint(keychain: nil, credentialsFile: nil) + } operation: { + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure( + now: start, + refreshTokenHash: "hash-old-lineage") + + ClaudeOAuthRefreshFailureGate.recordTransientFailure( + now: start.addingTimeInterval(1), + refreshTokenHash: "hash-new-lineage") + + guard case .transient = ClaudeOAuthRefreshFailureGate.currentBlockStatus( + now: start.addingTimeInterval(2)) + else { + Issue.record("Expected transient backoff after new-lineage transient failure") + return + } + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(2), + refreshTokenHash: "hash-new-lineage")) + } + } + + @Test + func `same lineage transient failure keeps the terminal block`() { + ClaudeOAuthRefreshFailureGate.resetForTesting() + defer { ClaudeOAuthRefreshFailureGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 92000) + ClaudeOAuthRefreshFailureGate.withFingerprintProviderOverrideForTesting { + ClaudeOAuthRefreshFailureGate.AuthFingerprint(keychain: nil, credentialsFile: nil) + } operation: { + ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure( + now: start, + refreshTokenHash: "hash-same-lineage") + + ClaudeOAuthRefreshFailureGate.recordTransientFailure( + now: start.addingTimeInterval(1), + refreshTokenHash: "hash-same-lineage") + ClaudeOAuthRefreshFailureGate.recordTransientFailure( + now: start.addingTimeInterval(2)) + + guard case .terminal = ClaudeOAuthRefreshFailureGate.currentBlockStatus( + now: start.addingTimeInterval(3)) + else { + Issue.record("Expected the terminal block to stay monotonic for the same lineage") + return + } + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(3), + refreshTokenHash: "hash-same-lineage")) + #expect(!ClaudeOAuthRefreshFailureGate.shouldAttempt( + now: start.addingTimeInterval(3))) + } + } } #endif From ceb458dae701e96bfd88b387b08a99e75feb786d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 14:26:31 -0700 Subject: [PATCH 6/6] fix: compose Claude ownership with keychain consent --- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 10 ++++ ...ClaudeOAuthDirectKeychainReadConsent.swift | 4 ++ ...entialsStoreCLIStorageOwnershipTests.swift | 22 ++++--- ...laudeOAuthRefreshChainOwnershipTests.swift | 57 ++++++++++++++++++- 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 16704f9506..5981c468d3 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1532,6 +1532,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 +1867,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) } @@ -2901,6 +2908,9 @@ public enum ClaudeOAuthCredentialsStore { if KeychainAccessGate.currentOverrideForTesting == true { return false } + if let consentOverride = ClaudeOAuthDirectKeychainReadConsent.taskOverrideForTesting { + return consentOverride + } if self.hasTaskKeychainTestingOverride { return true } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift index 5abb6b5d55..6011489140 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDirectKeychainReadConsent.swift @@ -18,6 +18,10 @@ public enum ClaudeOAuthDirectKeychainReadConsent { #if DEBUG @TaskLocal private static var taskOverride: Bool? + + static var taskOverrideForTesting: Bool? { + self.taskOverride + } #endif public static func isGranted(userDefaults: UserDefaults? = nil) -> Bool { 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/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(),