Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
_ = settings.claudeCookieSource
_ = settings.claudeCookieHeader
_ = settings.claudeOAuthKeychainPromptMode
_ = settings.claudeOAuthDirectKeychainReadAllowed
_ = settings.claudeOAuthKeychainReadStrategy
_ = settings.claudeWebExtrasEnabled
_ = settings.claudeSwapEnabled
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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/"))
}
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +623 to +624

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate cached Claude credentials on opt-out

When a user turns this setting back off after a successful OAuth read, the setter only flips the defaults flag. ClaudeOAuthCredentialsStore.loadRecord still returns valid in-memory or CodexBar keychain-cache entries before the keychainAccessAllowed-guarded freshness sync, so Claude OAuth usage can continue using the copied Claude Code token until the cache expires instead of immediately falling back to the CLI. Clear/invalidate the Claude OAuth credential cache when newValue is false.

Useful? React with 👍 / 👎.

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 {
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ extension SettingsStore {
_ = self.confettiOnSessionLimitResetsEnabled
_ = self.confettiOnWeeklyLimitResetsEnabled
_ = self.claudeOAuthKeychainPromptMode
_ = self.claudeOAuthDirectKeychainReadAllowed
_ = self.claudeOAuthKeychainReadStrategy
_ = self.claudeWebExtrasEnabled
_ = self.copilotBudgetExtrasEnabled
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -604,6 +607,7 @@ extension SettingsStore {
menuBarShowsHighestUsage: menuBarShowsHighestUsage,
claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw,
claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw,
claudeOAuthDirectKeychainReadAllowed: claudeOAuthDirectKeychainReadAllowed,
claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw,
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T>(
_ granted: Bool?,
operation: () throws -> T) rethrows -> T
{
try self.$taskOverride.withValue(granted) {
try operation()
}
}

public static func withTaskOverrideForTesting<T>(
_ granted: Bool?,
isolation _: isolated (any Actor)? = #isolation,
operation: () async throws -> T) async rethrows -> T
{
try await self.$taskOverride.withValue(granted) {
try await operation()
}
}
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading