Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ import Foundation

/// Split out of `ClaudeUsageFetcher.swift` to keep that file within the file-length limit.
extension ClaudeUsageFetcher {
/// 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.
static let unreadableCredentialsMessage =
"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."

/// True when no refresh can restore this profile: CodexBar never reads Claude Code's Keychain item in
/// production, so with no credentials file there is nothing a delegated refresh could hand back.
static func isDelegatedRefreshProvablyUnreadable(environment: [String: String]) -> Bool {
guard !ClaudeOAuthCredentialsStore.keychainAccessAllowed else { return false }
return !ClaudeOAuthCredentialsStore.hasSelectedProfileOAuthCredentialsFile(environment: environment)

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 Preserve manual refresh guidance until after a touch

When a background poll hits onlyOnUserAction before the user has tried a manual delegated refresh, the absence of a credentials file is not enough to prove the profile is unrecoverable: the delegated-refresh coordinator intentionally treats older Claude Code as potentially able to create that file during a retried touch (ClaudeOAuthDelegatedRefreshCoordinator.swift:307-311). Returning true here makes those recoverable cached CLI credentials display the terminal “Switch source” message instead of the only action that can create the file, so this should depend on an actual post-touch isUnreadableAfterRefresh verdict or equivalent persisted evidence.

Useful? React with 👍 / 👎.

}

static func delegatedRefreshOutcomeLabel(
_ outcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome) -> String
{
Expand Down Expand Up @@ -30,11 +44,7 @@ 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."
return Self.unreadableCredentialsMessage
}

switch result.outcome {
Expand Down
17 changes: 13 additions & 4 deletions Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
self.configuration.browserDetection
}

private struct ClaudeOAuthKeychainPromptPolicy {
struct ClaudeOAuthKeychainPromptPolicy {
let mode: ClaudeOAuthKeychainPromptMode
let isApplicable: Bool
let interaction: ProviderInteraction
Expand Down Expand Up @@ -243,9 +243,10 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
interaction: ProviderInteractionContext.current)
}

private static func assertDelegatedRefreshAllowedInCurrentInteraction(
static func assertDelegatedRefreshAllowedInCurrentInteraction(
policy: ClaudeOAuthKeychainPromptPolicy,
allowBackgroundDelegatedRefresh: Bool) throws
allowBackgroundDelegatedRefresh: Bool,
isProvablyUnreadable: Bool) throws
{
if policy.mode == .never {
throw ClaudeUsageError.oauthFailed("Delegated refresh is disabled by 'never' keychain policy.")
Expand All @@ -254,6 +255,12 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
policy.interaction != .userInitiated,
!allowBackgroundDelegatedRefresh
{
// Why: "Click Refresh" is a loop for a profile no refresh can restore — the user clicks, the
// delegated path reaches its terminal verdict, and the next background poll overwrites that verdict
// with this message again. Report the terminal outcome the delegated path would reach anyway.
if isProvablyUnreadable {
throw ClaudeUsageError.oauthFailed(unreadableCredentialsMessage)
}
throw ClaudeUsageError.oauthFailed(
"Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy "
+ "is set to only prompt on user action. Click Refresh in the CodexBar menu to retry.")
Expand Down Expand Up @@ -403,7 +410,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
let delegatedPromptPolicy = ClaudeUsageFetcher.currentClaudeOAuthDelegatedRefreshPolicy()
try ClaudeUsageFetcher.assertDelegatedRefreshAllowedInCurrentInteraction(
policy: delegatedPromptPolicy,
allowBackgroundDelegatedRefresh: self.fetcher.allowBackgroundDelegatedRefresh)
allowBackgroundDelegatedRefresh: self.fetcher.allowBackgroundDelegatedRefresh,
isProvablyUnreadable: ClaudeUsageFetcher.isDelegatedRefreshProvablyUnreadable(
environment: self.fetcher.environment))

let delegatedResult = await ClaudeUsageFetcher.attemptDelegatedRefresh(
environment: self.fetcher.environment)
Expand Down
101 changes: 101 additions & 0 deletions Tests/CodexBarTests/ClaudeUnrecoverableOAuthGuidanceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation
import Testing
@testable import CodexBarCore

/// Regression coverage for #2733: a profile no refresh can restore must not be told to click Refresh.
@Suite(.serialized)
struct ClaudeUnrecoverableOAuthGuidanceTests {
private func makeTemporaryDirectory() throws -> URL {
let root = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("codexbar-guidance-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return root
}

private func makeCredentialsData(expiresAt: Date) -> Data {
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
return Data("""
{
"claudeAiOauth": {
"accessToken": "from-file",
"expiresAt": \(millis),
"scopes": ["user:profile"]
}
}
""".utf8)
}

private func backgroundPolicy() -> ClaudeUsageFetcher.ClaudeOAuthKeychainPromptPolicy {
ClaudeUsageFetcher.ClaudeOAuthKeychainPromptPolicy(
mode: .onlyOnUserAction,
isApplicable: true,
interaction: .background)
}

private func message(from error: Error) -> String? {
guard case let ClaudeUsageError.oauthFailed(message) = error else { return nil }
return message
}

@Test
func `a profile no refresh can restore is told to switch source, not to click Refresh`() async throws {
let root = try self.makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }

// Keychain reads disabled (production always is) and no credentials file: nothing a delegated
// refresh produces can ever be read back, so "Click Refresh" would loop forever.
try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(
root.appendingPathComponent(".credentials.json"))
{
#expect(ClaudeUsageFetcher.isDelegatedRefreshProvablyUnreadable(environment: [:]))

let thrown = #expect(throws: ClaudeUsageError.self) {
try ClaudeUsageFetcher.assertDelegatedRefreshAllowedInCurrentInteraction(
policy: self.backgroundPolicy(),
allowBackgroundDelegatedRefresh: false,
isProvablyUnreadable: true)
}
#expect(self.message(from: thrown!) == ClaudeUsageFetcher.unreadableCredentialsMessage)
#expect(self.message(from: thrown!)?.contains("Click Refresh") != true)
}
}
}

@Test
func `a recoverable profile keeps the click Refresh guidance`() async throws {
let root = try self.makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let credentialsURL = root.appendingPathComponent(".credentials.json")
try self.makeCredentialsData(expiresAt: Date(timeIntervalSinceNow: 3600)).write(to: credentialsURL)

try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) {
// A credentials file is present, so a delegated refresh can still hand something back.
#expect(!ClaudeUsageFetcher.isDelegatedRefreshProvablyUnreadable(environment: [:]))

let thrown = #expect(throws: ClaudeUsageError.self) {
try ClaudeUsageFetcher.assertDelegatedRefreshAllowedInCurrentInteraction(
policy: self.backgroundPolicy(),
allowBackgroundDelegatedRefresh: false,
isProvablyUnreadable: false)
}
#expect(self.message(from: thrown!)?.contains("Click Refresh") == true)
}
}
}

@Test
func `a user initiated refresh is never blocked by the prompt policy`() throws {
let policy = ClaudeUsageFetcher.ClaudeOAuthKeychainPromptPolicy(
mode: .onlyOnUserAction,
isApplicable: true,
interaction: .userInitiated)
// Delegation must still run for user actions: on older Claude Code the touch itself can create the
// credentials file, which is exactly the case the terminal verdict must not pre-empt.
try ClaudeUsageFetcher.assertDelegatedRefreshAllowedInCurrentInteraction(
policy: policy,
allowBackgroundDelegatedRefresh: false,
isProvablyUnreadable: true)
}
}
43 changes: 30 additions & 13 deletions Tests/CodexBarTests/ClaudeUsageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,19 @@ struct ClaudeUsageTests {
let loadCounter = AsyncCounter()
let delegatedCounter = AsyncCounter()

// Pinned: a present credentials file means a refresh could still restore this profile, which is what
// makes the retry suggestion below genuine. Without pinning, the message would depend on whether the
// host running the tests happens to have one.
let root = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("codexbar-delegated-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let credentialsURL = root.appendingPathComponent(".credentials.json")
let expiresAt = Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)
try Data("""
{"claudeAiOauth":{"accessToken":"t","expiresAt":\(expiresAt),"scopes":["user:profile"]}}
""".utf8).write(to: credentialsURL)

let fetcher = ClaudeUsageFetcher(
browserDetection: BrowserDetection(cacheTTL: 0),
environment: [:],
Expand All @@ -291,23 +304,27 @@ struct ClaudeUsageTests {
}

do {
_ = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityFramework,
operation: {
try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
try await ProviderInteractionContext.$current.withValue(.background) {
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
delegatedOverride)
{
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
loadCredsOverride)
_ = try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) {
try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
.securityFramework,
operation: {
try await ClaudeOAuthKeychainPromptPreference
.withTaskOverrideForTesting(.onlyOnUserAction)
{
try await ProviderInteractionContext.$current.withValue(.background) {
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
delegatedOverride)
{
try await fetcher.loadLatestUsage(model: "sonnet")
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
loadCredsOverride)
{
try await fetcher.loadLatestUsage(model: "sonnet")
}
}
}
}
}
})
})
}
Issue.record("Expected delegated refresh to be suppressed in background")
} catch let error as ClaudeUsageError {
guard case let .oauthFailed(message) = error else {
Expand Down
58 changes: 46 additions & 12 deletions TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,54 @@ struct ClaudeOAuthDelegatedRefreshLinuxTests {
}

@Test
func appOAuthBackgroundRespectsPlatformKeychainPromptPolicy() async {
func appOAuthBackgroundRespectsPlatformKeychainPromptPolicy() async throws {
// A credentials file means a delegated refresh could still hand something back, so the retry
// suggestion is genuine and must be preserved.
let root = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("codexbar-linux-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let credentialsURL = root.appendingPathComponent(".credentials.json")
let expiresAt = Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)
try Data("""
{"claudeAiOauth":{"accessToken":"t","expiresAt":\(expiresAt),"scopes":["user:profile"]}}
""".utf8).write(to: credentialsURL)

let result = await self.runDelegatedRefresh(
runtime: .app,
interaction: .background,
promptMode: .onlyOnUserAction)
promptMode: .onlyOnUserAction,
credentialsURL: credentialsURL)

#expect(result.attempts == 0)
#expect(result.message.contains("background repair is suppressed"))
#expect(result.message.contains("Click Refresh in the CodexBar menu"))
#expect(!result.message.contains("Open the CodexBar menu or"))
}

@Test
func appOAuthBackgroundReportsUnrecoverableProfileInsteadOfSuggestingRefresh() async {
// No credentials file and no readable Claude Keychain item: a refresh cannot restore this profile,
// so "Click Refresh" would send the user round a loop that always lands back here.
let result = await self.runDelegatedRefresh(
runtime: .app,
interaction: .background,
promptMode: .onlyOnUserAction)

#expect(result.attempts == 0)
#expect(result.message == ClaudeUsageFetcher.unreadableCredentialsMessage)
#expect(!result.message.contains("Click Refresh in the CodexBar menu"))
}

private func runDelegatedRefresh(
runtime: ProviderRuntime,
interaction: ProviderInteraction,
promptMode: ClaudeOAuthKeychainPromptMode) async -> (attempts: Int, message: String)
promptMode: ClaudeOAuthKeychainPromptMode,
// Pinned so the suppression message does not depend on whether the host running the tests happens to
// have a Claude credentials file: its presence decides whether a refresh could restore this profile.
credentialsURL: URL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("codexbar-absent-\(UUID().uuidString)")
.appendingPathComponent(".credentials.json")) async -> (attempts: Int, message: String)
{
let counter = Counter()
let fetcher = ClaudeUsageFetcher(
Expand All @@ -95,15 +127,17 @@ struct ClaudeOAuthDelegatedRefreshLinuxTests {
}

do {
_ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) {
try await ProviderInteractionContext.$current.withValue(interaction) {
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride
.withValue(credentialsOverride) {
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride
.withValue(delegatedOverride) {
try await fetcher.loadLatestUsage(model: "sonnet")
}
}
_ = try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) {
try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) {
try await ProviderInteractionContext.$current.withValue(interaction) {
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride
.withValue(credentialsOverride) {
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride
.withValue(delegatedOverride) {
try await fetcher.loadLatestUsage(model: "sonnet")
}
}
}
}
}
Issue.record("Expected delegated-refresh path to fail with mocked stale credentials")
Expand Down