Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
73 changes: 61 additions & 12 deletions Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
}
}
Expand Down
Loading