From 987f6d696d916b885f228318f742601546986cd0 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:24:28 +0800 Subject: [PATCH 1/6] Fix Claude source mode fallback on successful CLI login (#2403) --- .../Providers/Claude/ClaudeLoginFlow.swift | 4 +- .../ClaudeIssue2403ReproductionTests.swift | 153 ++++++++++++++++++ .../ClaudeLoginFlowPolicyTests.swift | 3 +- 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index 29241390d0..a67f4f67d2 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -32,7 +32,9 @@ extension StatusItemController { self.loginLogger.info("Claude login", metadata: ["outcome": outcome, "length": "\(length)"]) 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 { + self.settings.claudeUsageDataSource = .oauth + } self.postLoginNotification(for: .claude) return true } diff --git a/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift b/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift new file mode 100644 index 0000000000..20e2207857 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift @@ -0,0 +1,153 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct ClaudeIssue2403ReproductionTests { + private func makeSettings(suiteSuffix: String) -> SettingsStore { + let suite = "ClaudeIssue2403ReproductionTests-\(suiteSuffix)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @Test + func `web refresh failure disconnects after login`() async throws { + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + + let settings = self.makeSettings(suiteSuffix: "web-unauthorized") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.providerDetectionCompleted = true + settings.claudeUsageDataSource = .web + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + // 1. Simulate user clicking "Sign in with Claude Code..." which runs login runner and succeeds + await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in + onPhaseChange(.requesting) + await Task.yield() + onPhaseChange(.waitingBrowser) + await Task.yield() + return ClaudeLoginRunner.Result( + outcome: .success, + output: "Successfully logged in", + authLink: nil) + } + + #expect(didLogin) + #expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata)) + } + + // 2. Immediately after login, store refreshes provider. + // If web API is unauthorized (e.g. browser not logged in), web fetch fails with unauthorized + store.errors[.claude] = ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription + + // 3. Verify menu actions immediately revert to re-login / disconnect state + let actions = MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + .sections + .flatMap(\.entries) + .compactMap { entry -> (String, MenuDescriptor.MenuAction)? in + guard case let .action(label, action) = entry else { return nil } + return (label, action) + } + + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + + @Test + func `login flow succeeds but stale token account causes immediate refresh failure and disconnect`() async throws { + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + + let settings = self.makeSettings(suiteSuffix: "token-account-stale") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.providerDetectionCompleted = true + settings.claudeUsageDataSource = .auto + + // User previously configured a stale/invalid token account + settings.addTokenAccount(provider: .claude, label: "Old Account", token: "invalid-cookie-session-token") + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + // 1. User signs in with Claude Code CLI + await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in + onPhaseChange(.requesting) + await Task.yield() + return ClaudeLoginRunner.Result( + outcome: .success, + output: "Logged in successfully", + authLink: nil) + } + + #expect(didLogin) + } + + // 2. Token account override causes fetch to fail on refresh + store.errors[.claude] = "Claude session key invalid" + + withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + let model = controller.menuCardModel(for: .claude) + #expect(model?.subtitleStyle == .error) + #expect(model?.subtitleText == "Claude session key invalid") + } + } + + @Test + func `oauth unauthorized error after login forces terminal re-auth prompt`() { + let settings = self.makeSettings(suiteSuffix: "oauth-unauthorized") + settings.claudeUsageDataSource = .oauth + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + store.errors[.claude] = ClaudeOAuthFetchError.unauthorized.localizedDescription + + let actions = MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + .sections + .flatMap(\.entries) + .compactMap { entry -> (String, MenuDescriptor.MenuAction)? in + guard case let .action(label, action) = entry else { return nil } + return (label, action) + } + + #expect(actions.contains { + $0.0 == "Open Terminal" && $0.1 == .openTerminal(command: "claude") + }) + } +} diff --git a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift index eeba8ccb68..235e8ac79c 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) ? .oauth : source + #expect(settings.claudeUsageDataSource == expectedSource) #expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata)) } } From eaf2a3ef70d71af3d75885a803f96513ff3c3421 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:29:02 +0800 Subject: [PATCH 2/6] Fix Claude source mode fallback on successful CLI login (#2403) and add swiftly retry --- .github/workflows/ci.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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" From b681186be5bf740ab1908444fef884faa4c245ed Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:31:06 +0800 Subject: [PATCH 3/6] Trigger CI rerun for PR #2514 From 0a152d903715379dbc953806292b3d19211f7331 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:38:13 +0800 Subject: [PATCH 4/6] Fix Claude source mode fallback on successful CLI login (#2403) --- .../ClaudeIssue2403ReproductionTests.swift | 153 ------------------ 1 file changed, 153 deletions(-) delete mode 100644 Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift diff --git a/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift b/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift deleted file mode 100644 index 20e2207857..0000000000 --- a/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift +++ /dev/null @@ -1,153 +0,0 @@ -import CodexBarCore -import Foundation -import Testing -@testable import CodexBar - -@MainActor -@Suite(.serialized) -struct ClaudeIssue2403ReproductionTests { - private func makeSettings(suiteSuffix: String) -> SettingsStore { - let suite = "ClaudeIssue2403ReproductionTests-\(suiteSuffix)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - return SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - } - - @Test - func `web refresh failure disconnects after login`() async throws { - let registry = ProviderRegistry.shared - let claudeMetadata = try #require(registry.metadata[.claude]) - - let settings = self.makeSettings(suiteSuffix: "web-unauthorized") - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.providerDetectionCompleted = true - settings.claudeUsageDataSource = .web - settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) - - let fetcher = UsageFetcher() - let store = UsageStore( - fetcher: fetcher, - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - // 1. Simulate user clicking "Sign in with Claude Code..." which runs login runner and succeeds - await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in - let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in - onPhaseChange(.requesting) - await Task.yield() - onPhaseChange(.waitingBrowser) - await Task.yield() - return ClaudeLoginRunner.Result( - outcome: .success, - output: "Successfully logged in", - authLink: nil) - } - - #expect(didLogin) - #expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata)) - } - - // 2. Immediately after login, store refreshes provider. - // If web API is unauthorized (e.g. browser not logged in), web fetch fails with unauthorized - store.errors[.claude] = ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription - - // 3. Verify menu actions immediately revert to re-login / disconnect state - let actions = MenuDescriptor.build( - provider: .claude, - store: store, - settings: settings, - account: fetcher.loadAccountInfo(), - updateReady: false) - .sections - .flatMap(\.entries) - .compactMap { entry -> (String, MenuDescriptor.MenuAction)? in - guard case let .action(label, action) = entry else { return nil } - return (label, action) - } - - #expect(actions.contains { - $0.0 == "Re-login at claude.ai" && $0.1 == .loginToProvider(url: "https://claude.ai/") - }) - } - - @Test - func `login flow succeeds but stale token account causes immediate refresh failure and disconnect`() async throws { - let registry = ProviderRegistry.shared - let claudeMetadata = try #require(registry.metadata[.claude]) - - let settings = self.makeSettings(suiteSuffix: "token-account-stale") - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.providerDetectionCompleted = true - settings.claudeUsageDataSource = .auto - - // User previously configured a stale/invalid token account - settings.addTokenAccount(provider: .claude, label: "Old Account", token: "invalid-cookie-session-token") - settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) - - let fetcher = UsageFetcher() - let store = UsageStore( - fetcher: fetcher, - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - // 1. User signs in with Claude Code CLI - await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in - let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in - onPhaseChange(.requesting) - await Task.yield() - return ClaudeLoginRunner.Result( - outcome: .success, - output: "Logged in successfully", - authLink: nil) - } - - #expect(didLogin) - } - - // 2. Token account override causes fetch to fail on refresh - store.errors[.claude] = "Claude session key invalid" - - withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in - let model = controller.menuCardModel(for: .claude) - #expect(model?.subtitleStyle == .error) - #expect(model?.subtitleText == "Claude session key invalid") - } - } - - @Test - func `oauth unauthorized error after login forces terminal re-auth prompt`() { - let settings = self.makeSettings(suiteSuffix: "oauth-unauthorized") - settings.claudeUsageDataSource = .oauth - - let fetcher = UsageFetcher() - let store = UsageStore( - fetcher: fetcher, - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - store.errors[.claude] = ClaudeOAuthFetchError.unauthorized.localizedDescription - - let actions = MenuDescriptor.build( - provider: .claude, - store: store, - settings: settings, - account: fetcher.loadAccountInfo(), - updateReady: false) - .sections - .flatMap(\.entries) - .compactMap { entry -> (String, MenuDescriptor.MenuAction)? in - guard case let .action(label, action) = entry else { return nil } - return (label, action) - } - - #expect(actions.contains { - $0.0 == "Open Terminal" && $0.1 == .openTerminal(command: "claude") - }) - } -} From c526a05781caa7e180dc9d8d9fb26b774c99118e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:17:18 +0800 Subject: [PATCH 5/6] Fix Claude source mode fallback on successful CLI login (#2403) --- .../Providers/Claude/ClaudeLoginFlow.swift | 1 + .../ClaudeLoginFlowPolicyTests.swift | 32 +++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index a67f4f67d2..bf48b50037 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -32,6 +32,7 @@ extension StatusItemController { self.loginLogger.info("Claude login", metadata: ["outcome": outcome, "length": "\(length)"]) 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 { self.settings.claudeUsageDataSource = .oauth } diff --git a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift index 235e8ac79c..4f7893a9ad 100644 --- a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift +++ b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift @@ -25,22 +25,28 @@ struct ClaudeLoginFlowTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in - let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in - onPhaseChange(.requesting) - await Task.yield() - onPhaseChange(.waitingBrowser) - await Task.yield() - return ClaudeLoginRunner.Result( - outcome: .success, - output: "Successfully logged in", - authLink: nil) - } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) - #expect(didLogin) - #expect(controller.loginPhase == .idle) + let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in + onPhaseChange(.requesting) + await Task.yield() + onPhaseChange(.waitingBrowser) + await Task.yield() + return ClaudeLoginRunner.Result( + outcome: .success, + output: "Successfully logged in", + authLink: nil) } + #expect(didLogin) + #expect(controller.loginPhase == .idle) + let expectedSource: ClaudeUsageDataSource = (source == .web) ? .oauth : source #expect(settings.claudeUsageDataSource == expectedSource) #expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata)) From d0d9b6d309b6dfb18d2cc2d2ee76ea987f1bd659 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:53:13 +0800 Subject: [PATCH 6/6] Preserve CLI fallback after login and restore test teardown (#2403) - Route migrated web logins through Auto so OAuth failure can fall back to the CLI credential when Keychain reads are unavailable - Restore withStatusItemControllerForTesting teardown in the login-flow policy test - Add issue 2403 reproduction coverage for post-login strategy planning Co-authored-by: Cursor --- .../Providers/Claude/ClaudeLoginFlow.swift | 4 +- .../Claude/ClaudeProviderDescriptor.swift | 8 + .../ClaudeIssue2403ReproductionTests.swift | 145 ++++++++++++++++++ .../ClaudeLoginFlowPolicyTests.swift | 34 ++-- 4 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index bf48b50037..0029b02cbb 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -34,7 +34,9 @@ extension StatusItemController { let metadata = self.store.metadata(for: .claude) self.settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) if self.settings.claudeUsageDataSource == .web { - self.settings.claudeUsageDataSource = .oauth + // 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 4f7893a9ad..2203aef497 100644 --- a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift +++ b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift @@ -25,29 +25,23 @@ struct ClaudeLoginFlowTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let controller = StatusItemController( - store: store, - settings: settings, - account: fetcher.loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: .system) + await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in + onPhaseChange(.requesting) + await Task.yield() + onPhaseChange(.waitingBrowser) + await Task.yield() + return ClaudeLoginRunner.Result( + outcome: .success, + output: "Successfully logged in", + authLink: nil) + } - let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in - onPhaseChange(.requesting) - await Task.yield() - onPhaseChange(.waitingBrowser) - await Task.yield() - return ClaudeLoginRunner.Result( - outcome: .success, - output: "Successfully logged in", - authLink: nil) + #expect(didLogin) + #expect(controller.loginPhase == .idle) } - #expect(didLogin) - #expect(controller.loginPhase == .idle) - - let expectedSource: ClaudeUsageDataSource = (source == .web) ? .oauth : source + let expectedSource: ClaudeUsageDataSource = (source == .web) ? .auto : source #expect(settings.claudeUsageDataSource == expectedSource) #expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata)) }