Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep cold CLI fallback behind user initiation

When a scheduled Auto refresh has an identifiable profile but no local OAuth cache/file, this return bypasses the existing foreground-establishment and .always prompt-policy gates. ClaudeCLIFetchStrategy.fetch then uses the .cli path, which goes directly to the interactive PTY without ClaudeCLIAuthStatusProbe; for a logged-out CLI this can open browser OAuth, and the opaque child can also surface Keychain UI, despite the refresh being in the background and the default policy being onlyOnUserAction. A confirmed absence of CodexBar-readable credentials does not establish that the interactive CLI is safe to launch unattended, so this exception should still require explicit background opt-in or a noninteractive logged-in preflight.

Useful? React with 👍 / 👎.

}
// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand Down
Loading