diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ad938e49..1df2ce1378 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" @@ -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" diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index 29241390d0..0029b02cbb 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -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 } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 7943471cb5..e7f66e621a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -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()] diff --git a/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift b/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift new file mode 100644 index 0000000000..88afb87cc6 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift @@ -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")) + } + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift index eeba8ccb68..2203aef497 100644 --- a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift +++ b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift @@ -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)) } }