Skip to content
Open
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
18 changes: 16 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,14 @@ jobs:
echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV"
echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV"

swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"
for i in 1 2 3 4 5; do
if swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"; then
break
fi
if [ "$i" -eq 5 ]; then exit 1; fi
echo "swiftly install failed, retrying in 5s (attempt $i/5)..."
sleep 5
done
if [[ -s "$POST_INSTALL_SCRIPT" ]]; then
sudo apt-get update
sudo bash "$POST_INSTALL_SCRIPT"
Expand Down Expand Up @@ -475,7 +482,14 @@ jobs:
echo "SWIFTLY_HOME_DIR=$SWIFTLY_HOME_DIR" >> "$GITHUB_ENV"
echo "SWIFTLY_BIN_DIR=$SWIFTLY_BIN_DIR" >> "$GITHUB_ENV"

swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"
for i in 1 2 3 4 5; do
if swiftly install "$SWIFT_VERSION" --use --assume-yes --verify --post-install-file "$POST_INSTALL_SCRIPT"; then
break
fi
if [ "$i" -eq 5 ]; then exit 1; fi
echo "swiftly install failed, retrying in 5s (attempt $i/5)..."
sleep 5
done
if [[ -s "$POST_INSTALL_SCRIPT" ]]; then
sudo apt-get update
sudo bash "$POST_INSTALL_SCRIPT"
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ extension StatusItemController {
if case .success = result.outcome {
let metadata = self.store.metadata(for: .claude)
self.settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
if self.settings.claudeUsageDataSource == .web {
// Route through Auto so refresh keeps the CLI fallback when OAuth Keychain
// reads are unavailable after a terminal CLI login.
self.settings.claudeUsageDataSource = .auto
}
self.postLoginNotification(for: .claude)
return true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public enum ClaudeProviderDescriptor {
}))
}

#if DEBUG
public static func resolveStrategiesForTesting(
context: ProviderFetchContext) async -> [any ProviderFetchStrategy]
{
await self.resolveStrategies(context: context)
}
#endif

private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] {
if context.sourceMode == .api || self.hasAutoAdminAPIKey(context: context) {
return [ClaudeAdminAPIFetchStrategy()]
Expand Down
145 changes: 145 additions & 0 deletions Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Foundation
import Testing
@testable import CodexBarCore

@Suite(.serialized)
struct ClaudeIssue2403ReproductionTests {
private struct StubClaudeFetcher: ClaudeUsageFetching {
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
throw ClaudeUsageError.parseFailed("stub")
}

func debugRawProbe(model _: String) async -> String {
"stub"
}

func detectVersion() -> String? {
nil
}
}

private func makeContext(
sourceMode: ProviderSourceMode,
env: [String: String] = [:],
settings: ProviderSettingsSnapshot? = nil,
selectedTokenAccountID: UUID? = nil) -> ProviderFetchContext
{
ProviderFetchContext(
runtime: .app,
sourceMode: sourceMode,
includeCredits: false,
webTimeout: 1,
webDebugDumpHTML: false,
verbose: false,
env: env,
settings: settings,
fetcher: UsageFetcher(environment: env),
claudeFetcher: StubClaudeFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
selectedTokenAccountID: selectedTokenAccountID)
}

private func makeClaudeKeychainData(accessToken: String = "cli-token") -> Data {
let expiresAt = Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)
return Data("""
{
"claudeAiOauth": {
"accessToken": "\(accessToken)",
"refreshToken": "refresh-token",
"expiresAt": \(expiresAt),
"scopes": ["user:profile"]
}
}
""".utf8)
}

private func makeFakeClaudeCLI() throws -> String {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-2403-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let scriptURL = directory.appendingPathComponent("claude")
try "#!/bin/sh\nexit 0\n".write(to: scriptURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
return scriptURL.path
}

private func makeAutoSettings() -> ProviderSettingsSnapshot {
ProviderSettingsSnapshot.make(claude: .init(
usageDataSource: .auto,
webExtrasEnabled: false,
cookieSource: .off,
manualCookieHeader: nil,
organizationID: nil))
}

@Test
func `web to auto post-login keeps OAuth first and CLI fallback with CLI credential present`() async throws {
// Issue #2403: a terminal `claude` login succeeds while Keychain access is disabled,
// so the post-login refresh must not strand the user on strict OAuth.
let fakeCLI = try self.makeFakeClaudeCLI()
let keychainData = self.makeClaudeKeychainData()
let settings = self.makeAutoSettings()

try await KeychainAccessGate.withTaskOverrideForTesting(true) {
try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: keychainData,
fingerprint: nil)
{
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI) {
let context = self.makeContext(sourceMode: .auto, settings: settings)
let strategies = await ClaudeProviderDescriptor.resolveStrategiesForTesting(context: context)
let ids = strategies.map(\.id)

#expect(ids.first == "claude.oauth")
#expect(ids.contains("claude.cli"))

let oauthIndex = try #require(ids.firstIndex(of: "claude.oauth"))
let cliIndex = try #require(ids.firstIndex(of: "claude.cli"))
#expect(oauthIndex < cliIndex)

let oauth = strategies[oauthIndex]
#expect(oauth.shouldFallback(on: ClaudeOAuthCredentialsError.notFound, context: context))
}
}
}
}

@Test
func `auto mode with saved admin API key still prefers admin API after login migration`() async {
// Codex P2: a saved admin key intentionally wins Auto planning; the web -> auto
// migration must not silently keep routing these users through OAuth.
let env = ["ANTHROPIC_ADMIN_KEY": "sk-ant-admin-redacted"]
let settings = self.makeAutoSettings()

let context = self.makeContext(sourceMode: .auto, env: env, settings: settings)
let strategies = await ClaudeProviderDescriptor.resolveStrategiesForTesting(context: context)
#expect(strategies.map(\.id) == ["claude.admin-api"])
}

@Test
func `auto mode after login does not reroute refresh to a selected token account`() async throws {
let fakeCLI = try self.makeFakeClaudeCLI()
let keychainData = self.makeClaudeKeychainData()
let settings = self.makeAutoSettings()

try await KeychainAccessGate.withTaskOverrideForTesting(true) {
try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
data: keychainData,
fingerprint: nil)
{
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI) {
let context = self.makeContext(
sourceMode: .auto,
settings: settings,
selectedTokenAccountID: UUID())
let strategies = await ClaudeProviderDescriptor.resolveStrategiesForTesting(context: context)
let ids = strategies.map(\.id)

#expect(!ids.contains("claude.admin-api"))
#expect(ids.first == "claude.oauth")
#expect(ids.contains("claude.cli"))
}
}
}
}
}
3 changes: 2 additions & 1 deletion Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ struct ClaudeLoginFlowTests {
#expect(controller.loginPhase == .idle)
}

#expect(settings.claudeUsageDataSource == source)
let expectedSource: ClaudeUsageDataSource = (source == .web) ? .auto : source
#expect(settings.claudeUsageDataSource == expectedSource)
#expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata))
}
}
Expand Down