From 907644516e82a8d2738b0c172fd9dccf009fda03 Mon Sep 17 00:00:00 2001 From: axisrow <93047788+axisrow@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:05:56 +0700 Subject: [PATCH 1/6] fix: gate CLI background usage fetch on confirmed-absent OAuth creds (#1) * fix: gate CLI background usage fetch on confirmed-absent OAuth creds Add directCredentialIsMissingOverride TaskLocal for deterministic testing and thread an oauthCredentialsConfirmedAbsent check into ClaudeCLIBackgroundAvailability.allowsBackgroundAutoUsageFetch so the CLI background fallback only proceeds when direct OAuth credentials are confirmed absent. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HqBGFKF9M8etkRpgz2bk2Z * fix: honor CLI background revocation before the OAuth absence fallback allowsBackgroundAutoUsageFetch's deadlock-breaker only checked isEstablished(), which drops a marker as soon as it's revoked by a failed foreground fetch. That let the OAuth-absence probe re-permit a background CLI usage attempt on every tick after a failure, defeating the existing revocation/backoff guarantee. Check isRevoked() before falling through to oauthCredentialsConfirmedAbsent so a revoked marker stays denied until the next foreground success. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HqBGFKF9M8etkRpgz2bk2Z * fix: require an identified profile before the OAuth absence fallback allowsBackgroundAutoUsageFetch's deadlock-breaker could still fire for a profile ClaudeAccountProfile.identifiedSessionScope can't identify (missing/malformed/unreadable account config), since isEstablished and the round-1 isRevoked check both silently return false when captureMarker is nil. That let a background CLI fetch launch with no stable account binding, and a failed attempt could never be recorded as a revocation (revoke() needs a marker), so nothing would bound repeated launches. Require a non-nil marker before consulting oauthCredentialsConfirmedAbsent, matching the fail-closed contract identifiedSessionScope already documents for background work. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HqBGFKF9M8etkRpgz2bk2Z --------- Co-authored-by: axisrow Co-authored-by: Claude Sonnet 5 --- .../Claude/ClaudeProviderDescriptor.swift | 42 ++- .../ClaudeBaselineCharacterizationTests.swift | 73 ++++- ...ClaudeCLIBackgroundAvailabilityTests.swift | 301 ++++++++++++------ ...ClaudeOAuthUpgradeCompatibilityTests.swift | 47 +-- 4 files changed, 323 insertions(+), 140 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index c150c5fc9b..ffcfb62b2a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -528,6 +528,7 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { #if DEBUG @TaskLocal static var nonInteractiveCredentialRecordOverride: ClaudeOAuthCredentialRecord? @TaskLocal static var claudeCLIAvailableOverride: Bool? + @TaskLocal static var directCredentialIsMissingOverride: Bool? #endif private func loadNonInteractiveCredentialRecord(environment: [String: String]) -> ClaudeOAuthCredentialRecord? { @@ -546,6 +547,9 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { func directCredentialIsMissing(environment: [String: String]) -> Bool { #if DEBUG + if let override = Self.directCredentialIsMissingOverride { + return override + } if Self.nonInteractiveCredentialRecordOverride != nil { return false } @@ -986,7 +990,10 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false } return ClaudeCLIBackgroundAvailability.allowsBackgroundAutoUsageFetch( binary: binary, - environment: context.env) + environment: context.env, + oauthCredentialsConfirmedAbsent: { + ClaudeOAuthFetchStrategy().directCredentialIsMissing(environment: context.env) + }) } // The interactive Claude REPL can open browser OAuth when it starts logged out. CLI-runtime paths @@ -1100,10 +1107,39 @@ enum ClaudeCLIBackgroundAvailability { || ClaudeOAuthKeychainPromptPreference.storedMode() == .always } - static func allowsBackgroundAutoUsageFetch(binary: String, environment: [String: String]) -> Bool { + /// - Parameter oauthCredentialsConfirmedAbsent: A prompt-free, no-UI probe proving the OAuth step ahead + /// of this one is durably dead (not merely denied). Consulted lazily, only when no marker exists at + /// all for this profile — a marker that *is* established but denied by prompt policy or Keychain- + /// disable revocation is a deliberate, already-adjudicated gate that this never second-guesses. + static func allowsBackgroundAutoUsageFetch( + binary: String, + environment: [String: String], + oauthCredentialsConfirmedAbsent: () -> Bool = { false }) -> Bool + { guard ProviderInteractionContext.current == .background else { return true } guard KeychainAccessGate.isExplicitlyDisabled else { - return self.allowsOpaqueChildExecution(binary: binary, environment: environment) + if self.allowsOpaqueChildExecution(binary: binary, environment: environment) { + return true + } + guard !self.isEstablished(binary: binary, environment: environment) else { return false } + // The deadlock-breaker below requires a profile CodexBar can actually identify. Without one, + // a failed attempt could never be recorded via `revoke()` (which needs a marker), so nothing + // would ever bound repeated background launches — the same fail-closed contract + // `identifiedSessionScope` documents for background work in general. + guard let marker = self.captureMarker(binary: binary, environment: environment) else { return false } + // A marker that was established and then revoked by a failed foreground fetch is a deliberate, + // already-adjudicated "not available right now" outcome — `isEstablished` alone can't see it, + // since revocation removes the marker from the established set. The deadlock-breaker below + // exists only for profiles that never reached user-initiated status at all; a revoked profile + // already tried and must wait for the next foreground success, not be re-permitted here. + if self.store.isRevoked(marker) { + return false + } + // The marker gate above never gets a chance to be set when the OAuth step ahead of this one + // is durably dead: it is only recorded by a prior *successful* user-initiated CLI fetch, and a + // scheduled refresh never reaches user-initiated status. Breaking that deadlock here mirrors + // explicit OAuth mode's own absence check (`ClaudeOAuthPlanningAvailability`). + return oauthCredentialsConfirmedAbsent() } // Disable Keychain explicitly permits one owner-CLI usage attempt on a cold profile. A failed attempt // records revocation below, preventing each background timer tick from retrying until a foreground success. diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 965f818f74..7a88d82ae1 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -262,7 +262,9 @@ struct ClaudeBaselineCharacterizationTests { } @Test - func `app background auto does not start Claude CLI before foreground availability`() async throws { + func `app background auto does not start Claude CLI while OAuth credentials may still be readable`() + async throws + { let settings = ProviderSettingsSnapshot.make(claude: .init( usageDataSource: .auto, webExtrasEnabled: false, @@ -273,10 +275,12 @@ struct ClaudeBaselineCharacterizationTests { let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) let env = ["CLAUDE_CLI_PATH": stubCLIPath] + // Without a durable "OAuth is dead" signal, background Auto never starts CLI here: no established + // marker exists (cold launch) and there is no confirmed credential absence yet. await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { await self.withBackgroundKeychainAccess { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await self.withNoOAuthCredentials { + await ClaudeOAuthFetchStrategy.$directCredentialIsMissingOverride.withValue(false) { let outcome = await self.fetchOutcome( runtime: .app, sourceMode: .auto, @@ -285,12 +289,47 @@ struct ClaudeBaselineCharacterizationTests { #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) } } } } + } - #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + @Test + func `app background auto starts Claude CLI once OAuth credentials are confirmed absent`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let invocationLog = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") + let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + + // A direct, non-interactive read confirming OAuth credentials are absent (not merely + // unreadable/denied) breaks the deadlock: this is the one background-Auto case where CLI must + // start even without a prior foreground-established marker, because there is otherwise no path + // out of a durably dead OAuth step. The stub here reports logged-out, so `loadViaAutoCLI` fails + // fast on the auth-status check — but it was tried, which is the point of this fallback. + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + await self.withBackgroundKeychainAccess { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await self.withNoOAuthCredentials { + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .auto, + env: env, + settings: settings) + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + #expect(FileManager.default.fileExists(atPath: invocationLog.path)) + } + } + } + } } @Test @@ -313,17 +352,20 @@ struct ClaudeBaselineCharacterizationTests { sourceMode: .auto, env: env, settings: settings) - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) + // OAuth credentials are confirmed absent here (`withNoOAuthCredentials`), so the + // deadlock-breaker starts CLI even without a prior foreground-established marker. + // The stub is logged in and returns valid usage, so the pipeline never reaches web. + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) } } } - #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + #expect(FileManager.default.fileExists(atPath: invocationLog.path)) } @Test - func `app background auto falls back to web without probing Claude CLI`() async throws { + func `app background auto starts Claude CLI over web once OAuth credentials are confirmed absent`() async throws { let settings = ProviderSettingsSnapshot.make(claude: .init( usageDataSource: .auto, webExtrasEnabled: false, @@ -362,10 +404,13 @@ struct ClaudeBaselineCharacterizationTests { } let result = try outcome.result.get() - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [true, false, true]) - #expect(result.strategyID == "claude.web") - #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) + // OAuth credentials are confirmed absent (`withNoOAuthCredentials`), so the deadlock-breaker + // starts CLI even without a prior foreground-established marker. The stub is logged in and + // returns valid usage, so the manual web cookie is never needed. + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + #expect(result.strategyID == "claude.cli") + #expect(FileManager.default.fileExists(atPath: invocationLog.path)) } @Test @@ -389,7 +434,11 @@ struct ClaudeBaselineCharacterizationTests { .securityCLIExperimental) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - await cli.isAvailable(context) + // Present-but-denied by policy here, not a durable OAuth absence, so the + // no-established-marker deadlock-breaker must not fire for this stored policy check. + await ClaudeOAuthFetchStrategy.$directCredentialIsMissingOverride.withValue(false) { + await cli.isAvailable(context) + } } } } diff --git a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift index c6ef578184..fb63bf7aca 100644 --- a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift @@ -10,16 +10,8 @@ struct ClaudeCLIBackgroundAvailabilityTests { defer { try? FileManager.default.removeItem(at: profile.root) } let context = self.makeContext(environment: profile.environment) - await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - await KeychainAccessGate.withTaskOverrideForTesting(true) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - await ProviderInteractionContext.$current.withValue(.background) { - #expect(await strategy.isAvailable(context)) - } - } - } - } + await self.withBackgroundGates(keychainDisabled: true, promptMode: .never) { + #expect(await strategy.isAvailable(context)) } } @@ -30,16 +22,9 @@ struct ClaudeCLIBackgroundAvailabilityTests { defer { try? FileManager.default.removeItem(at: profile.root) } let context = self.makeContext(environment: profile.environment) - await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - await KeychainAccessGate.withTaskOverrideForTesting(false) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - await ProviderInteractionContext.$current.withValue(.background) { - #expect(await !strategy.isAvailable(context)) - } - } - } - } + // Credentials are present-but-denied here, not durably absent, so the deadlock-breaker must not fire. + await self.withBackgroundGates(keychainDisabled: false, promptMode: .always, oauthCredentialsMissing: false) { + #expect(await !strategy.isAvailable(context)) } } @@ -50,17 +35,13 @@ struct ClaudeCLIBackgroundAvailabilityTests { defer { try? FileManager.default.removeItem(at: profile.root) } let context = self.makeContext(environment: profile.environment) - await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: context.env) - await KeychainAccessGate.withTaskOverrideForTesting(true) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - await ProviderInteractionContext.$current.withValue(.background) { - #expect(await strategy.isAvailable(context)) - } - } - } - } + await self.withBackgroundGates( + keychainDisabled: true, + promptMode: .never, + establishedBinary: "/bin/echo", + establishedEnvironment: context.env) + { + #expect(await strategy.isAvailable(context)) } } @@ -71,17 +52,13 @@ struct ClaudeCLIBackgroundAvailabilityTests { defer { try? FileManager.default.removeItem(at: profile.root) } let context = self.makeContext(environment: profile.environment) - await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: context.env) - await KeychainAccessGate.withTaskOverrideForTesting(false) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - await ProviderInteractionContext.$current.withValue(.background) { - #expect(await !strategy.isAvailable(context)) - } - } - } - } + await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .onlyOnUserAction, + establishedBinary: "/bin/echo", + establishedEnvironment: context.env) + { + #expect(await !strategy.isAvailable(context)) } } @@ -92,17 +69,13 @@ struct ClaudeCLIBackgroundAvailabilityTests { defer { try? FileManager.default.removeItem(at: profile.root) } let context = self.makeContext(environment: profile.environment) - await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: context.env) - await KeychainAccessGate.withTaskOverrideForTesting(false) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - await ProviderInteractionContext.$current.withValue(.background) { - #expect(await strategy.isAvailable(context)) - } - } - } - } + await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .always, + establishedBinary: "/bin/echo", + establishedEnvironment: context.env) + { + #expect(await strategy.isAvailable(context)) } } @@ -111,21 +84,37 @@ struct ClaudeCLIBackgroundAvailabilityTests { let strategy = self.makeStrategy() let context = self.makeContext(sourceMode: .oauth) - await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - await KeychainAccessGate.withTaskOverrideForTesting(true) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - await ProviderInteractionContext.$current.withValue(.background) { - #expect(await !strategy.isAvailable(context)) - } - } + await self.withBackgroundGates(keychainDisabled: true, promptMode: promptMode) { + #expect(await !strategy.isAvailable(context)) + } + } + + @Test + func `failed disabled Keychain exception revokes later background Auto usage`() async throws { + let strategy = self.makeStrategy() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) + let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws + -> ClaudeStatusSnapshot = { _, _, _ in + throw ExpectedFetchError.failed + } + + // Present-but-denied throughout: a revoked marker after a failed fetch is not the same as a + // durable OAuth absence, so the deadlock-breaker must not fire. + await self.withBackgroundGates(keychainDisabled: true, promptMode: .never, oauthCredentialsMissing: false) { + #expect(await strategy.isAvailable(context)) + await #expect(throws: ExpectedFetchError.self) { + try await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + try await strategy.fetch(context) } } + #expect(await !strategy.isAvailable(context)) } } @Test - func `failed disabled Keychain exception revokes later background Auto usage`() async throws { + func `enabled Keychain revocation is not bypassed by the OAuth absence deadlock breaker`() async throws { let strategy = self.makeStrategy() let profile = try self.makeProfile(accountID: "account-a") defer { try? FileManager.default.removeItem(at: profile.root) } @@ -135,22 +124,51 @@ struct ClaudeCLIBackgroundAvailabilityTests { throw ExpectedFetchError.failed } - await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - await KeychainAccessGate.withTaskOverrideForTesting(true) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - await ProviderInteractionContext.$current.withValue(.background) { - #expect(await strategy.isAvailable(context)) - await #expect(throws: ExpectedFetchError.self) { - try await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { - try await strategy.fetch(context) - } - } - #expect(await !strategy.isAvailable(context)) - } - } + // A revoked marker is a deliberate, already-adjudicated "not available right now" outcome from a + // failed foreground fetch. It must not be second-guessed by the deadlock-breaker even when OAuth + // credentials are confirmed durably absent — that escape hatch exists only for profiles that never + // reached user-initiated status at all, not for ones that tried and failed. + await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .always, + establishedBinary: "/bin/echo", + establishedEnvironment: context.env, + oauthCredentialsMissing: true) + { + #expect(await strategy.isAvailable(context)) + await #expect(throws: ExpectedFetchError.self) { + try await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + try await strategy.fetch(context) } } + #expect(await !strategy.isAvailable(context)) + } + } + + @Test + func `enabled Keychain does not fall back to the OAuth absence probe for an unidentified profile`() + async throws + { + let strategy = self.makeStrategy() + // No profile is created here: the config file is verifiably absent, so + // `ClaudeAccountProfile.identifiedSessionScope` returns nil and `captureMarker` can never produce a + // marker for this environment — there is nothing to establish, revoke, or bind a background attempt to. + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-unidentified-profile-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let context = self.makeContext(environment: ["CLAUDE_CONFIG_DIR": root.path]) + + // Keychain enabled, prompt policy not `.always`, no established marker: `allowsBackgroundAutoUsageFetch` + // would deny on its own. The deadlock-breaker only exists to unblock a profile CodexBar can identify + // but has never seen a successful foreground fetch for — it must not fire for a profile with no + // identity at all, since a failed attempt here could never be recorded as a revocation. + await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .onlyOnUserAction, + oauthCredentialsMissing: true) + { + #expect(await !strategy.isAvailable(context)) } } @@ -178,18 +196,58 @@ struct ClaudeCLIBackgroundAvailabilityTests { let contextA = self.makeContext(environment: profileA.environment) let contextB = self.makeContext(environment: profileB.environment) - await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: contextA.env) - await KeychainAccessGate.withTaskOverrideForTesting(false) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - await ProviderInteractionContext.$current.withValue(.background) { - #expect(await strategy.isAvailable(contextA)) - #expect(await !strategy.isAvailable(contextB)) - } - } - } - } + // Profile B is present-but-denied here (a different account, not a durable OAuth absence), so + // the deadlock-breaker must not fire for it. + await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .always, + establishedBinary: "/bin/echo", + establishedEnvironment: contextA.env, + oauthCredentialsMissing: false) + { + #expect(await strategy.isAvailable(contextA)) + #expect(await !strategy.isAvailable(contextB)) + } + } + + @Test + func `background Auto CLI falls back without an established marker when OAuth credentials are absent`() + async throws + { + let strategy = self.makeStrategy() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) + + // Keychain access enabled and prompt policy not `.always`: `allowsBackgroundAutoUsageFetch` denies + // on its own (no established marker), so a `true` result here can only come from the + // deadlock-breaker below, not from the pre-existing cold-profile opt-in path. + await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .onlyOnUserAction, + oauthCredentialsMissing: true) + { + #expect(await strategy.isAvailable(context)) + } + } + + @Test + func `background Auto CLI stays blocked without an established marker when OAuth credentials are merely denied`() + async throws + { + let strategy = self.makeStrategy() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) + + // Credentials exist but are transiently unreadable/denied (not a durable absence) — the + // deadlock-breaker must not fire here, only for a confirmed "credentials not found" signal. + await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .onlyOnUserAction, + oauthCredentialsMissing: false) + { + #expect(await !strategy.isAvailable(context)) } } @@ -200,22 +258,21 @@ struct ClaudeCLIBackgroundAvailabilityTests { defer { try? FileManager.default.removeItem(at: profile.root) } let context = self.makeContext(environment: profile.environment) - try await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { - ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo", environment: context.env) - try await KeychainAccessGate.withTaskOverrideForTesting(false) { - try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { - try await ProviderInteractionContext.$current.withValue(.background) { - #expect(await strategy.isAvailable(context)) - try Data(#"{"oauthAccount":{"accountUuid":"account-b"}}"#.utf8) - .write(to: profile.configURL, options: .atomic) - #expect(await !strategy.isAvailable(context)) - try FileManager.default.removeItem(at: profile.configURL) - #expect(await !strategy.isAvailable(context)) - } - } - } - } + // Credentials are present-but-denied once the active account no longer matches the established + // marker, not durably absent, so the deadlock-breaker must not fire here. + try await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .always, + establishedBinary: "/bin/echo", + establishedEnvironment: context.env, + oauthCredentialsMissing: false) + { + #expect(await strategy.isAvailable(context)) + try Data(#"{"oauthAccount":{"accountUuid":"account-b"}}"#.utf8) + .write(to: profile.configURL, options: .atomic) + #expect(await !strategy.isAvailable(context)) + try FileManager.default.removeItem(at: profile.configURL) + #expect(await !strategy.isAvailable(context)) } } @@ -267,4 +324,40 @@ struct ClaudeCLIBackgroundAvailabilityTests { configURL: configURL, environment: ["CLAUDE_CONFIG_DIR": root.path]) } + + /// Assembles the gate stack every test in this file needs: an isolated background-availability + /// store, the Keychain-disable/prompt-policy pair under test, the resolved CLI binary, a background + /// interaction context, and (optionally) a pre-established marker or a pinned + /// `directCredentialIsMissing` answer for the OAuth-absence deadlock-breaker. + /// - Parameter oauthCredentialsMissing: `nil` (the default) leaves `directCredentialIsMissingOverride` + /// unset, matching production when no test needs to pin that specific signal. + private func withBackgroundGates( + keychainDisabled: Bool, + promptMode: ClaudeOAuthKeychainPromptMode, + binary: String = "/bin/echo", + establishedBinary: String? = nil, + establishedEnvironment: [String: String]? = nil, + oauthCredentialsMissing: Bool? = nil, + operation: () async throws -> T) async rethrows -> T + { + try await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + if let establishedBinary { + ClaudeCLIBackgroundAvailability.establish( + binary: establishedBinary, + environment: establishedEnvironment ?? [:]) + } + return try await KeychainAccessGate.withTaskOverrideForTesting(keychainDisabled) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(binary) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeOAuthFetchStrategy.$directCredentialIsMissingOverride + .withValue(oauthCredentialsMissing) { + try await operation() + } + } + } + } + } + } + } } diff --git a/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift index a7572fcec6..c45d59ec2b 100644 --- a/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift @@ -451,7 +451,7 @@ struct ClaudeOAuthUpgradeCompatibilityTests { } @Test - func `background Auto does not launch owner CLI before foreground establishment`() async throws { + func `background Auto launches owner CLI once OAuth credentials are confirmed absent`() async throws { let root = try Self.makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } let cli = try Self.makeFakeClaudeCLI(in: root) @@ -469,13 +469,20 @@ struct ClaudeOAuthUpgradeCompatibilityTests { #expect(binary == cli.executable.path) return Self.makeCLIUsageSnapshot() } - let outcome = try await KeychainAccessGate.withTaskOverrideForTesting(false) { - try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - try await ClaudeStatusProbe.$fetchOverride.withValue(cliUsage) { - try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { - try await Self.withForeignKeychainTripwires(calls: calls) { - await Self.withWebTripwires(calls: calls) { - await descriptor.fetchOutcome(context: context) + // No direct-Keychain-read consent (#2634) is the real production gate here — model it explicitly + // instead of relying on the ambient test-shortcut that `hasTaskKeychainTestingOverride` grants once + // any Keychain fixture is installed. Without consent, the non-interactive absence probe used by the + // background-Auto deadlock-breaker resolves to `.notFound` from the missing local cache/file alone, + // and never reaches the foreign Keychain tripwires below. + let outcome = try await ClaudeOAuthDirectKeychainReadConsent.withTaskOverrideForTesting(false) { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try await ClaudeStatusProbe.$fetchOverride.withValue(cliUsage) { + try await Self.withIsolatedCredentialState(credentialsURLOverride: missingCredentials) { + try await Self.withForeignKeychainTripwires(calls: calls) { + await Self.withWebTripwires(calls: calls) { + await descriptor.fetchOutcome(context: context) + } } } } @@ -483,22 +490,20 @@ struct ClaudeOAuthUpgradeCompatibilityTests { } } - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) - switch outcome.result { - case let .failure(error as ClaudeOAuthCredentialsError): - guard case .notFound = error else { - Issue.record("Expected missing OAuth credentials, got \(error)") - return - } - case let .failure(error): - Issue.record("Expected missing OAuth credentials, got \(error)") - case let .success(result): - Issue.record("Background Auto unexpectedly produced \(result.strategyID)") - } + // Without a prior foreground-established marker, background Auto now starts the owner CLI once a + // direct, non-interactive, no-prompt read confirms OAuth credentials are absent (not merely + // unreadable/denied) — the same deadlock-breaker documented on `ClaudeCLIFetchStrategy.isAvailable`. + // The fixture's owner CLI is logged in and returns real usage, so web is never consulted. + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + let result = try outcome.result.get() + #expect(result.strategyID == "claude.cli") #expect(calls.recordedOAuthTokens.isEmpty) #expect(calls.recordedWebCalls.isEmpty) #expect(calls.recordedForeignKeychainReads == 0) + // `ClaudeCLIFetchStrategy.fetch()` calls `loadViaCLI` directly (PTY `/usage`), not the auth-status + // preflight `loadViaAutoCLI` uses — `cliUsage` intercepts at the `ClaudeStatusProbe.fetch()` level, + // so the fixture binary is never actually spawned here and its invocation log stays empty. #expect(Self.cliInvocations(at: cli.invocationLog).isEmpty) } From f7ddb5e9d2c180e8ebe2804426e5aed288e1a7e7 Mon Sep 17 00:00:00 2001 From: axisrow Date: Sun, 9 Aug 2026 19:30:13 +0700 Subject: [PATCH 2/6] fix: require explicit background opt-in before the OAuth absence fallback Address Codex's P1 review finding on the upstream PR: a confirmed absence of CodexBar-readable OAuth credentials does not by itself prove the interactive Claude CLI is safe to launch unattended in the background. The deadlock-breaker in allowsBackgroundAutoUsageFetch now requires the same explicit background opt-in (.always prompt policy) that allowsOpaqueChildExecution already requires, instead of relying solely on the credential-absence probe. Update tests to cover both the opted-in and not-opted-in cases. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HqBGFKF9M8etkRpgz2bk2Z --- .../Claude/ClaudeProviderDescriptor.swift | 6 ++- .../ClaudeBaselineCharacterizationTests.swift | 14 ++++--- ...ClaudeCLIBackgroundAvailabilityTests.swift | 41 ++++++++++++++----- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index ffcfb62b2a..a62f06acce 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -1138,7 +1138,11 @@ enum ClaudeCLIBackgroundAvailability { // The marker gate above never gets a chance to be set when the OAuth step ahead of this one // is durably dead: it is only recorded by a prior *successful* user-initiated CLI fetch, and a // scheduled refresh never reaches user-initiated status. Breaking that deadlock here mirrors - // explicit OAuth mode's own absence check (`ClaudeOAuthPlanningAvailability`). + // explicit OAuth mode's own absence check (`ClaudeOAuthPlanningAvailability`). A confirmed + // absence of CodexBar-readable credentials does not by itself prove the interactive CLI is + // safe to launch unattended, so this exception still requires the same explicit background + // opt-in (`.always` prompt policy) that `allowsOpaqueChildExecution` requires above. + guard ClaudeOAuthKeychainPromptPreference.storedMode() == .always else { return false } return oauthCredentialsConfirmedAbsent() } // Disable Keychain explicitly permits one owner-CLI usage attempt on a cold profile. A failed attempt diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 7a88d82ae1..28a59960b2 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -352,16 +352,18 @@ struct ClaudeBaselineCharacterizationTests { sourceMode: .auto, env: env, settings: settings) - // OAuth credentials are confirmed absent here (`withNoOAuthCredentials`), so the - // deadlock-breaker starts CLI even without a prior foreground-established marker. - // The stub is logged in and returns valid usage, so the pipeline never reaches web. - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + // OAuth credentials are confirmed absent here (`withNoOAuthCredentials`), but the + // deadlock-breaker still requires the user's explicit background opt-in (`.always`) + // before launching the interactive CLI unattended — a confirmed absence of + // CodexBar-readable credentials does not by itself prove the CLI is safe to launch. + // With the stored policy left at `.onlyOnUserAction`, the pipeline stops at web. + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) } } } - #expect(FileManager.default.fileExists(atPath: invocationLog.path)) + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) } @Test diff --git a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift index fb63bf7aca..3f2ca17000 100644 --- a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift @@ -159,13 +159,14 @@ struct ClaudeCLIBackgroundAvailabilityTests { defer { try? FileManager.default.removeItem(at: root) } let context = self.makeContext(environment: ["CLAUDE_CONFIG_DIR": root.path]) - // Keychain enabled, prompt policy not `.always`, no established marker: `allowsBackgroundAutoUsageFetch` - // would deny on its own. The deadlock-breaker only exists to unblock a profile CodexBar can identify - // but has never seen a successful foreground fetch for — it must not fire for a profile with no - // identity at all, since a failed attempt here could never be recorded as a revocation. + // `.always` isolates this test to the unidentified-profile gate specifically, rather than + // incidentally passing because of the separate explicit-opt-in requirement. The deadlock-breaker + // only exists to unblock a profile CodexBar can identify but has never seen a successful + // foreground fetch for — it must not fire for a profile with no identity at all, since a failed + // attempt here could never be recorded as a revocation. await self.withBackgroundGates( keychainDisabled: false, - promptMode: .onlyOnUserAction, + promptMode: .always, oauthCredentialsMissing: true) { #expect(await !strategy.isAvailable(context)) @@ -211,7 +212,7 @@ struct ClaudeCLIBackgroundAvailabilityTests { } @Test - func `background Auto CLI falls back without an established marker when OAuth credentials are absent`() + func `background Auto CLI falls back without an established marker when OAuth absence prompts are allowed`() async throws { let strategy = self.makeStrategy() @@ -219,18 +220,38 @@ struct ClaudeCLIBackgroundAvailabilityTests { defer { try? FileManager.default.removeItem(at: profile.root) } let context = self.makeContext(environment: profile.environment) - // Keychain access enabled and prompt policy not `.always`: `allowsBackgroundAutoUsageFetch` denies - // on its own (no established marker), so a `true` result here can only come from the - // deadlock-breaker below, not from the pre-existing cold-profile opt-in path. + // A confirmed absence of CodexBar-readable credentials does not by itself prove the interactive + // CLI is safe to launch unattended, so the deadlock-breaker still requires the same explicit + // background opt-in (`.always`) that the pre-existing opaque-child gate requires. await self.withBackgroundGates( keychainDisabled: false, - promptMode: .onlyOnUserAction, + promptMode: .always, oauthCredentialsMissing: true) { #expect(await strategy.isAvailable(context)) } } + @Test + func `background Auto CLI stays blocked without an established marker when OAuth absence prompts are not allowed`() + async throws + { + let strategy = self.makeStrategy() + let profile = try self.makeProfile(accountID: "account-a") + defer { try? FileManager.default.removeItem(at: profile.root) } + let context = self.makeContext(environment: profile.environment) + + // Even a durable, confirmed absence of OAuth credentials must not launch the interactive CLI + // unattended without the user's explicit background opt-in. + await self.withBackgroundGates( + keychainDisabled: false, + promptMode: .onlyOnUserAction, + oauthCredentialsMissing: true) + { + #expect(await !strategy.isAvailable(context)) + } + } + @Test func `background Auto CLI stays blocked without an established marker when OAuth credentials are merely denied`() async throws From 401d9f2505125ceef0679738f36bc4ce7b6c81e2 Mon Sep 17 00:00:00 2001 From: axisrow Date: Sun, 9 Aug 2026 22:48:10 +0700 Subject: [PATCH 3/6] fix: preserve original auth error during background Claude web recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaudeWebAPIFetcher.fetchUsageSerialized cleared a stale cached session cookie on an auth failure and unconditionally fell through to a browser cookie read to recover, exactly the pattern fixed for Ollama in PR #2814. In a background context BrowserCookieAccessGate typically denies that read (no interactive Keychain prompt outside a user-initiated action), so extractSessionKeyInfo finds no session key and the generic 'no session key found' error replaces the original, more informative cached-auth error (e.g. the OAuth-absence message) — surfacing as the same intermittent flicker already reported for Claude. Still attempt browser-cookie recovery after clearing the stale cache, even in a background context — BrowserCookieAccessGate already gates that read on its own no-UI preflight (Safari never needs Keychain decryption, and a Chromium browser with a prior 'Always Allow' Keychain grant is read without a prompt) — so a background attempt is not unconditionally blocked. Only when that attempt also fails do we now surface the original cached-auth error instead of the misleading generic one. Adds ClaudeWebBackgroundRecoveryTests covering: background recovery that finds nothing (original error surfaces), background recovery that succeeds without a prompt (still works), and the equivalent user-initiated case. --- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 39 ++-- .../ClaudeWebBackgroundRecoveryTests.swift | 186 ++++++++++++++++++ 2 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeWebBackgroundRecoveryTests.swift diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 315b5f3240..6f7a47d86f 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -1385,6 +1385,7 @@ extension ClaudeWebAPIFetcher { { let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") } var cacheObservation = CookieHeaderCache.observeForConditionalMutation(provider: .claude) + var invalidatedCacheError: FetchError? if let cached = cacheObservation.entry, !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1400,6 +1401,7 @@ extension ClaudeWebAPIFetcher { case .unauthorized, .noSessionKeyFound, .invalidSessionKey: let cleared = CookieHeaderCache.clearIfCurrent(provider: .claude, expected: cached) cacheObservation = .authoritative(cleared ? nil : cached) + invalidatedCacheError = error default: throw error } @@ -1408,17 +1410,32 @@ extension ClaudeWebAPIFetcher { } } - let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log) - log("Found session key (\(sessionInfo.cookieCount) cookies)") - - return try await self.fetchUsage( - using: sessionInfo, - options: options, - logger: log, - cachePersistence: CachePersistence( - sourceLabel: sessionInfo.sourceLabel, - expectedObservation: cacheObservation, - persistInitialSessionKey: true)) + // The claude.ai session cookie can rotate independently of the user's signed-in state, so a background + // refresh can see a cached cookie go stale even when the user never signed out. Still attempt browser + // recovery here rather than assuming it will fail: BrowserCookieAccessGate already gates the read on its + // own no-UI preflight (Safari never needs Keychain decryption, and a Chromium browser with a prior + // "Always Allow" Keychain grant is also read without a prompt), so a background attempt is not + // unconditionally denied. Only if that attempt itself comes back empty do we surface the original, + // more informative cached-auth error instead of a misleading "no session key found" — mirroring the + // equivalent Ollama recovery in `OllamaStatusFetchStrategy.fetchAutomatic`. + do { + let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log) + log("Found session key (\(sessionInfo.cookieCount) cookies)") + + return try await self.fetchUsage( + using: sessionInfo, + options: options, + logger: log, + cachePersistence: CachePersistence( + sourceLabel: sessionInfo.sourceLabel, + expectedObservation: cacheObservation, + persistInitialSessionKey: true)) + } catch { + if let invalidatedCacheError { + throw invalidatedCacheError + } + throw error + } } } #endif diff --git a/Tests/CodexBarTests/ClaudeWebBackgroundRecoveryTests.swift b/Tests/CodexBarTests/ClaudeWebBackgroundRecoveryTests.swift new file mode 100644 index 0000000000..95acb8dbeb --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebBackgroundRecoveryTests.swift @@ -0,0 +1,186 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Covers the same background-refresh recovery shape as `OllamaUsageFetcherRetryMappingTests`, applied to +/// `ClaudeWebAPIFetcher.fetchUsageSerialized`: a background refresh must still attempt browser-cookie recovery +/// after a stale cached cookie is invalidated (the gate itself decides whether that read needs an interactive +/// prompt), but must surface the original, more informative cached-auth error — not a generic "no session key +/// found" — when that recovery attempt comes back empty. +@Suite(.serialized) +struct ClaudeWebBackgroundRecoveryTests { + @Test + func `background refresh surfaces original auth error when browser recovery finds nothing`() async { + await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-stale-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + + await #expect(throws: ClaudeWebAPIFetcher.FetchError.self) { + try await ProviderInteractionContext.$current.withValue(.background) { + try await self.withClaudeWebStub { request in + let isStale = request.value(forHTTPHeaderField: "Cookie") == + "sessionKey=sk-ant-stale-token" + if request.url?.path == "/api/organizations", isStale { + let url = try #require(request.url) + return Self.jsonResponse(url: url, body: "{}", statusCode: 401, setCookie: nil) + } + return try Self.response(for: request, setCookie: nil) + } operation: { + // No `ClaudeWebSessionKeyImport.overrideForTesting` is installed, so browser recovery + // finds no candidates — mirroring a real background attempt where no browser yields a + // session key. + _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + } + } + } + + // The stale cache was cleared by the invalidation attempt (matches the Ollama behavior); only the + // *error surfaced to the caller* is what this test guards. + #expect(CookieHeaderCache.load(provider: .claude) == nil) + } + } + + @Test + func `background refresh still recovers when browser cookie read succeeds without a prompt`() async throws { + try await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-stale-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + let imported = ClaudeWebAPIFetcher.SessionKeyInfo( + key: "sk-ant-imported-token", + sourceLabel: "Safari", + cookieCount: 1) + + try await ProviderInteractionContext.$current.withValue(.background) { + try await ClaudeWebSessionKeyImport.$overrideForTesting.withValue(imported) { + try await self.withClaudeWebStub { request in + let isStale = request.value(forHTTPHeaderField: "Cookie") == + "sessionKey=sk-ant-stale-token" + if request.url?.path == "/api/organizations", isStale { + let url = try #require(request.url) + return Self.jsonResponse(url: url, body: "{}", statusCode: 401, setCookie: nil) + } + return try Self.response(for: request, setCookie: nil) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0)) + #expect(usage.sessionPercentUsed == 11) + } + } + } + + let cached = try #require(CookieHeaderCache.load(provider: .claude)) + #expect(cached.cookieHeader == "sessionKey=sk-ant-imported-token") + #expect(cached.sourceLabel == "Safari") + } + } + + @Test + func `user initiated refresh surfaces original auth error when browser recovery finds nothing`() async { + await self.withIsolatedCookieCache { + CookieHeaderCache.store( + provider: .claude, + cookieHeader: "sessionKey=sk-ant-stale-token", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .claude) } + + await #expect(throws: ClaudeWebAPIFetcher.FetchError.self) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await self.withClaudeWebStub { request in + let isStale = request.value(forHTTPHeaderField: "Cookie") == + "sessionKey=sk-ant-stale-token" + if request.url?.path == "/api/organizations", isStale { + let url = try #require(request.url) + return Self.jsonResponse(url: url, body: "{}", statusCode: 401, setCookie: nil) + } + return try Self.response(for: request, setCookie: nil) + } operation: { + _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) + } + } + } + } + } + + // MARK: - Helpers (mirrors ClaudeWebCookieRenewalTests' stub/cache isolation) + + private func withIsolatedCookieCache(_ operation: () async throws -> T) async rethrows -> T { + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-web-background-recovery-\(UUID().uuidString)", isDirectory: true) + return try await KeychainCacheStore.withServiceOverrideForTesting( + "claude-web-background-recovery-\(UUID().uuidString)") + { + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + return try await operation() + } + } + } + + private func withClaudeWebStub( + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data), + operation: () async throws -> T) async rethrows -> T + { + let transport = ProviderHTTPTransportHandler { request in + let (response, data) = try handler(request) + return (data, response) + } + return try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await operation() + } + } + + private static func response( + for request: URLRequest, + setCookie: String?) throws -> (HTTPURLResponse, Data) + { + let url = try #require(request.url) + switch url.path { + case "/api/organizations": + return self.jsonResponse( + url: url, + body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#, + setCookie: setCookie) + case "/api/organizations/org-123/usage": + return self.jsonResponse( + url: url, + body: """ + { + "five_hour": { "utilization": 11 }, + "seven_day": { "utilization": 22 } + } + """, + setCookie: setCookie) + case "/api/account", "/api/organizations/org-123/overage_spend_limit": + return self.jsonResponse(url: url, body: "{}", statusCode: 404, setCookie: setCookie) + default: + return self.jsonResponse(url: url, body: "{}", statusCode: 404, setCookie: setCookie) + } + } + + private static func jsonResponse( + url: URL, + body: String, + statusCode: Int = 200, + setCookie: String?) -> (HTTPURLResponse, Data) + { + var headerFields = ["Content-Type": "application/json"] + if let setCookie { + headerFields["Set-Cookie"] = setCookie + } + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headerFields)! + return (response, Data(body.utf8)) + } +} From 5614710c5ac2406ce8ab7b35613085d87629373a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 04:27:21 -0700 Subject: [PATCH 4/6] test: isolate identified Claude profile in baseline background-Auto tests The background-Auto deadlock-breaker requires an identified Claude profile (captureMarker -> identifiedSessionScope -> accountConfigURL). Without CLAUDE_CONFIG_DIR in the test environment, accountConfigURL falls back to the host's real ~/.claude.json, so these tests passed only on machines with a signed-in Claude CLI and failed closed on CI where that file is absent. Give each affected test its own identified profile via an isolated CLAUDE_CONFIG_DIR so they prove the gate's behavior instead of the host's sign-in state. --- .../ClaudeBaselineCharacterizationTests.swift | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 28a59960b2..d8c63dc9fd 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -15,6 +15,15 @@ struct ClaudeBaselineCharacterizationTests { invocationLog: invocationLog) } + private func makeIdentifiedClaudeProfile() throws -> (root: URL, environment: [String: String]) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-baseline-profile-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data(#"{"oauthAccount":{"accountUuid":"baseline-account"}}"#.utf8) + .write(to: root.appendingPathComponent(".config.json"), options: .atomic) + return (root: root, environment: ["CLAUDE_CONFIG_DIR": root.path]) + } + private func makeStubClaudeCLI(authStatusScript: String, invocationLog: URL? = nil) throws -> String { let sample = """ Current session @@ -273,10 +282,13 @@ struct ClaudeBaselineCharacterizationTests { let invocationLog = FileManager.default.temporaryDirectory .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) - let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let profile = try self.makeIdentifiedClaudeProfile() + defer { try? FileManager.default.removeItem(at: profile.root) } + let env = ["CLAUDE_CLI_PATH": stubCLIPath].merging(profile.environment) { current, _ in current } - // Without a durable "OAuth is dead" signal, background Auto never starts CLI here: no established - // marker exists (cold launch) and there is no confirmed credential absence yet. + // The isolated profile is identified so this reaches the credential-absence check without depending + // on the host's real ~/.claude.json. Without a durable "OAuth is dead" signal, background Auto never + // starts CLI here: no established marker exists (cold launch) and there is no confirmed absence yet. await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { await self.withBackgroundKeychainAccess { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { @@ -306,7 +318,9 @@ struct ClaudeBaselineCharacterizationTests { let invocationLog = FileManager.default.temporaryDirectory .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) - let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let profile = try self.makeIdentifiedClaudeProfile() + defer { try? FileManager.default.removeItem(at: profile.root) } + let env = ["CLAUDE_CLI_PATH": stubCLIPath].merging(profile.environment) { current, _ in current } // A direct, non-interactive read confirming OAuth credentials are absent (not merely // unreadable/denied) breaks the deadlock: this is the one background-Auto case where CLI must @@ -342,7 +356,9 @@ struct ClaudeBaselineCharacterizationTests { let invocationLog = FileManager.default.temporaryDirectory .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) - let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let profile = try self.makeIdentifiedClaudeProfile() + defer { try? FileManager.default.removeItem(at: profile.root) } + let env = ["CLAUDE_CLI_PATH": stubCLIPath].merging(profile.environment) { current, _ in current } await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityCLIExperimental) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { @@ -352,11 +368,10 @@ struct ClaudeBaselineCharacterizationTests { sourceMode: .auto, env: env, settings: settings) - // OAuth credentials are confirmed absent here (`withNoOAuthCredentials`), but the - // deadlock-breaker still requires the user's explicit background opt-in (`.always`) - // before launching the interactive CLI unattended — a confirmed absence of - // CodexBar-readable credentials does not by itself prove the CLI is safe to launch. - // With the stored policy left at `.onlyOnUserAction`, the pipeline stops at web. + // The identified test profile reaches the deadlock-breaker's explicit background opt-in + // guard. OAuth credentials are confirmed absent here (`withNoOAuthCredentials`), but a + // confirmed absence of CodexBar-readable credentials does not by itself prove the CLI is + // safe to launch. With the stored policy left at `.onlyOnUserAction`, the pipeline stops at web. #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) #expect(outcome.attempts.map(\.wasAvailable) == [true, false, false]) } @@ -376,7 +391,9 @@ struct ClaudeBaselineCharacterizationTests { let invocationLog = FileManager.default.temporaryDirectory .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) - let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let profile = try self.makeIdentifiedClaudeProfile() + defer { try? FileManager.default.removeItem(at: profile.root) } + let env = ["CLAUDE_CLI_PATH": stubCLIPath].merging(profile.environment) { current, _ in current } let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in ClaudeUsageSnapshot( primary: RateWindow( @@ -425,7 +442,9 @@ struct ClaudeBaselineCharacterizationTests { let invocationLog = FileManager.default.temporaryDirectory .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) - let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let profile = try self.makeIdentifiedClaudeProfile() + defer { try? FileManager.default.removeItem(at: profile.root) } + let env = ["CLAUDE_CLI_PATH": stubCLIPath].merging(profile.environment) { current, _ in current } let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) @@ -436,8 +455,9 @@ struct ClaudeBaselineCharacterizationTests { .securityCLIExperimental) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - // Present-but-denied by policy here, not a durable OAuth absence, so the - // no-established-marker deadlock-breaker must not fire for this stored policy check. + // The identified test profile reaches the policy guard, where `.onlyOnUserAction` stops the + // deadlock-breaker before credential absence is even evaluated. The override pins the + // absence signal to false anyway so this stays a pure stored-policy check either way. await ClaudeOAuthFetchStrategy.$directCredentialIsMissingOverride.withValue(false) { await cli.isAvailable(context) } From ebfbfb737253950fc33fdc8569958a64d5e91ac9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 04:45:01 -0700 Subject: [PATCH 5/6] test: split explicit-mode Claude baseline tests into an extension Merging main pushed ClaudeBaselineCharacterizationTests past the 800-line type_body_length limit; move the explicit source-mode resolution and token heuristic tests into a MARK-ed extension. --- .../ClaudeBaselineCharacterizationTests.swift | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 858aca7812..57c72d1c38 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -852,6 +852,24 @@ struct ClaudeBaselineCharacterizationTests { } } + private static func makeUsageStatusSnapshot() -> ClaudeStatusSnapshot { + ClaudeStatusSnapshot( + sessionPercentLeft: 88, + weeklyPercentLeft: 60, + opusPercentLeft: 95, + accountEmail: "user@example.com", + accountOrganization: "Example Org", + loginMethod: nil, + primaryResetDescription: "Resets 11am", + secondaryResetDescription: "Resets Nov 21", + opusResetDescription: "Resets Nov 21", + rawText: "stub") + } +} + +// MARK: - Explicit source-mode resolution and token heuristics + +extension ClaudeBaselineCharacterizationTests { @Test(arguments: [ (ProviderSourceMode.cli, "claude.cli"), (ProviderSourceMode.web, "claude.web"), @@ -901,18 +919,4 @@ struct ClaudeBaselineCharacterizationTests { #expect(!TokenAccountSupportCatalog.isClaudeOAuthToken("sessionKey=sk-ant-session")) #expect(!TokenAccountSupportCatalog.isClaudeOAuthToken("Cookie: sessionKey=sk-ant-session; foo=bar")) } - - private static func makeUsageStatusSnapshot() -> ClaudeStatusSnapshot { - ClaudeStatusSnapshot( - sessionPercentLeft: 88, - weeklyPercentLeft: 60, - opusPercentLeft: 95, - accountEmail: "user@example.com", - accountOrganization: "Example Org", - loginMethod: nil, - primaryResetDescription: "Resets 11am", - secondaryResetDescription: "Resets Nov 21", - opusResetDescription: "Resets Nov 21", - rawText: "stub") - } } From 3d6edcee6643aeb1c9339ed9529cfc6492adadd1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 05:39:22 -0700 Subject: [PATCH 6/6] test: isolate identified Claude profile in upgrade-compat deadlock test Same host-state leak as the baseline suite: without CLAUDE_CONFIG_DIR the deadlock-breaker's marker guard reads the host's real ~/.claude.json, so the test passed only on signed-in dev machines and failed on CI. --- .../ClaudeOAuthUpgradeCompatibilityTests.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift index c45d59ec2b..d9d08796c1 100644 --- a/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift @@ -456,9 +456,17 @@ struct ClaudeOAuthUpgradeCompatibilityTests { defer { try? FileManager.default.removeItem(at: root) } let cli = try Self.makeFakeClaudeCLI(in: root) let missingCredentials = root.appendingPathComponent("missing-credentials.json") + // The deadlock-breaker only fires for a profile CodexBar can identify. Pin an isolated identified + // profile via CLAUDE_CONFIG_DIR so this does not depend on the host's real ~/.claude.json + // (signed in on dev machines, absent on CI). + try Data(#"{"oauthAccount":{"accountUuid":"upgrade-compat-account"}}"#.utf8) + .write(to: root.appendingPathComponent(".config.json"), options: .atomic) let context = try self.makePersistedOAuthContext( suite: "ClaudeOAuthUpgradeCompatibilityTests-auto-foreign-only", - environment: ["CLAUDE_CLI_PATH": cli.executable.path], + environment: [ + "CLAUDE_CLI_PATH": cli.executable.path, + "CLAUDE_CONFIG_DIR": root.path, + ], sourceMode: .auto) let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context)