diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 6faf111a6c..dc891aca19 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -529,6 +529,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? { @@ -547,6 +548,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 } @@ -993,7 +997,10 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { // fetch when Keychain access is explicitly disabled; version/auth children retain the global gate. return ClaudeCLIBackgroundAvailability.allowsBackgroundAutoUsageFetch( binary: binary, - environment: context.env) + environment: context.env, + oauthCredentialsConfirmedAbsent: { + ClaudeOAuthFetchStrategy().directCredentialIsMissing(environment: context.env) + }) } // App user actions intentionally launch the interactive path directly so the user can complete authentication. @@ -1103,10 +1110,43 @@ 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`). 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 // records revocation below, preventing each background timer tick from retrying until a foreground success. 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/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index d8125b23f6..57c72d1c38 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 @@ -393,7 +402,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, @@ -402,12 +413,17 @@ 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 } + // 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) { - await self.withNoOAuthCredentials { + await ClaudeOAuthFetchStrategy.$directCredentialIsMissingOverride.withValue(false) { let outcome = await self.fetchOutcome( runtime: .app, sourceMode: .auto, @@ -416,12 +432,49 @@ 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 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 + // 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 @@ -434,7 +487,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) { @@ -444,6 +499,10 @@ struct ClaudeBaselineCharacterizationTests { sourceMode: .auto, env: env, settings: settings) + // 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]) } @@ -454,7 +513,7 @@ struct ClaudeBaselineCharacterizationTests { } @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, @@ -463,7 +522,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( @@ -493,10 +554,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 @@ -509,7 +573,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) @@ -520,7 +586,12 @@ struct ClaudeBaselineCharacterizationTests { .securityCLIExperimental) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - await cli.isAvailable(context) + // 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) + } } } } @@ -781,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"), @@ -830,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") - } } diff --git a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift index c6ef578184..3f2ca17000 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,52 @@ 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]) + + // `.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: .always, + oauthCredentialsMissing: true) + { + #expect(await !strategy.isAvailable(context)) } } @@ -178,18 +197,78 @@ 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 absence prompts are 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) + + // 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: .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 + { + 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 +279,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 +345,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..d9d08796c1 100644 --- a/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthUpgradeCompatibilityTests.swift @@ -451,14 +451,22 @@ 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) 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) @@ -469,13 +477,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 +498,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) } 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)) + } +}