Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,16 @@ extension ClaudeOAuthCredentialsStore {
guard !keychainAccessDisabled || self.isolatedSecurityCLIKeychainPath(environment: environment) != nil else {
return false
}
guard ClaudeOAuthKeychainPromptPreference.effectiveMode(readStrategy: readStrategy) != .never else {
let promptMode = ClaudeOAuthKeychainPromptPreference.effectiveMode(readStrategy: readStrategy)

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 Use stored prompt policy for security CLI MCP probes

When the experimental security CLI reader is selected, effectiveMode(readStrategy:) maps the stored .onlyOnUserAction or .never preference to .always, so this MCP-only check still launches /usr/bin/security during background expired-credential handling before delegated refresh is suppressed. This leaves a background Keychain prompt path open for users on securityCLIExperimental; use the stored prompt mode for this child-process boundary as well.

Useful? React with 👍 / 👎.

guard promptMode != .never else {
return false
}
// A Security.framework query configured as "no UI" can still display a legacy Keychain ACL dialog.
// Respect the user-action-only policy before background discovery touches Claude Code credentials.
guard readStrategy != .securityFramework
|| promptMode != .onlyOnUserAction
|| interaction == .userInitiated
else {
return false
}
let payload: Data? = switch readStrategy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator {

public enum Outcome: Sendable, Equatable {
case skippedByCooldown
case skippedByPromptPolicy
case cliUnavailable
case attemptedSucceeded
case attemptedFailed(String)
Expand Down Expand Up @@ -59,7 +60,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator {
let outcome = await task.value
self.clearInFlightTaskIfStillCurrent(id: id, state: state)
switch outcome {
case .attemptedFailed, .skippedByCooldown, .cliUnavailable:
case .attemptedFailed, .skippedByCooldown, .skippedByPromptPolicy, .cliUnavailable:
return await self.attempt(now: now, timeout: timeout, environment: environment)
case .attemptedSucceeded:
return outcome
Expand All @@ -81,6 +82,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator {
let environment: [String: String]
let interaction: ProviderInteraction
let readStrategy: ClaudeOAuthKeychainReadStrategy
let promptMode: ClaudeOAuthKeychainPromptMode
let keychainAccessDisabled: Bool
#if DEBUG
let cliAvailableOverride: Bool?
Expand Down Expand Up @@ -113,20 +115,28 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator {
let attemptID = state.nextAttemptID
// Detached to avoid inheriting the caller's executor context (e.g. MainActor) and cancellation state.
#if DEBUG
let readStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()
let configuration = AttemptConfiguration(
environment: environment,
interaction: interaction,
readStrategy: ClaudeOAuthKeychainReadStrategyPreference.current(),
readStrategy: readStrategy,
// The delegated Claude process is an opaque Keychain boundary. Its policy must come
// from the user's stored preference, not the strategy-adjusted mode used by our own reads.
promptMode: ClaudeOAuthKeychainPromptPreference.storedMode(),
keychainAccessDisabled: KeychainAccessGate.isDisabled,
cliAvailableOverride: self.cliAvailableOverrideForTesting,
touchAuthPathOverride: self.touchAuthPathOverrideForTesting,
keychainFingerprintOverride: self.keychainFingerprintOverrideForTesting)
let securityCLIReadOverride = ClaudeOAuthCredentialsStore.currentSecurityCLIReadOverrideForTesting()
#else
let readStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()
let configuration = AttemptConfiguration(
environment: environment,
interaction: interaction,
readStrategy: ClaudeOAuthKeychainReadStrategyPreference.current(),
readStrategy: readStrategy,
// The delegated Claude process is an opaque Keychain boundary. Its policy must come
// from the user's stored preference, not the strategy-adjusted mode used by our own reads.
promptMode: ClaudeOAuthKeychainPromptPreference.storedMode(),
keychainAccessDisabled: KeychainAccessGate.isDisabled)
#endif
let task = Task.detached(priority: .utility) {
Expand Down Expand Up @@ -158,6 +168,16 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator {
configuration: AttemptConfiguration,
state: AttemptStateStorage) async -> Outcome
{
// `/status` is an opaque Claude CLI invocation and may launch `/usr/bin/security` outside
// CodexBar's own no-UI query controls. Background work may not cross that boundary unless
// the user explicitly opted into always allowing Keychain access.
if configuration.interaction == .background,
configuration.keychainAccessDisabled || configuration.promptMode != .always
Comment on lines +174 to +175

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 Honor disabled Keychain access for user refreshes

If KeychainAccessGate.isDisabled is true but the expired OAuth repair is triggered by a user action, this guard does not return because it is scoped to background interactions only. The coordinator then continues to touchOAuthAuthPath and launches claude /status, which can invoke /usr/bin/security, so the global “Disable Keychain access” setting is bypassed for explicit refresh/OAuth-source repairs.

Useful? React with 👍 / 👎.

{
self.log.info("Claude OAuth delegated refresh skipped by Keychain prompt policy")
return .skippedByPromptPolicy
}

guard self.isClaudeCLIAvailable(environment: configuration.environment, configuration: configuration) else {
self.log.info("Claude OAuth delegated refresh skipped: claude CLI unavailable")
return .cliUnavailable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,14 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy {
guard sourceMode == .auto else { return true }
guard claudeCLIAvailable else { return false }
guard ProviderInteractionContext.current == .background else { return true }
// An expired Claude CLI credential requires the delegated Claude CLI refresh path.
// That child process can access Keychain outside CodexBar's no-UI controls, so do
// not plan it during background Auto refresh without an explicit opt-in.
guard !KeychainAccessGate.isDisabled,
ClaudeOAuthKeychainPromptPreference.current() == .always
else {
return false
}
return !Self.hasMcpOAuthOnlyClaudeKeychainPayload(environment: environment)
case .environment:
return sourceMode != .auto
Expand Down Expand Up @@ -641,12 +649,23 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
let hasWebFallback: Bool

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
// The interactive Claude REPL can open browser OAuth when it starts logged out. CLI runtime and background
// app Auto refreshes must establish authentication through the non-interactive status command first.
let requiresAuthPreflight = context.runtime == .cli || (
context.runtime == .app &&
context.sourceMode == .auto &&
ProviderInteractionContext.current == .background)
// Claude's "auth status" command is a child process that may invoke /usr/bin/security itself. A no-prompt
// policy in CodexBar cannot constrain that child process, so background Auto refresh must not launch it
// unless the user explicitly opted into Keychain access for background work.
let isBackgroundAutoRefresh = context.runtime == .app
&& context.sourceMode == .auto
&& ProviderInteractionContext.current == .background
if isBackgroundAutoRefresh {
guard !KeychainAccessGate.isDisabled,
ClaudeOAuthKeychainPromptPreference.current() == .always

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 Honor stored prompt mode before CLI auth preflight

In background Auto refresh with the experimental securityCLIExperimental reader selected, ClaudeOAuthKeychainPromptPreference.current() returns the effective mode .always even when the stored user preference is still the default .onlyOnUserAction. That lets this guard pass and then runs ClaudeCLIAuthStatusProbe.isLoggedIn (claude auth status --json) in the background, reintroducing the opaque Claude CLI Keychain path this change is trying to block unless the user explicitly opted into always allowing prompts. Use the stored prompt mode for this child-process boundary, as the delegated refresh path now does.

Useful? React with 👍 / 👎.

else {
return false
}
}

// The interactive Claude REPL can open browser OAuth when it starts logged out. CLI runtime and the
// explicitly opted-in background Auto path establish authentication through the status command first.
let requiresAuthPreflight = context.runtime == .cli || isBackgroundAutoRefresh
guard requiresAuthPreflight else { return true }
guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false }
return await ClaudeCLIAuthStatusProbe.isLoggedIn(binary: binary, environment: context.env)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
{
throw ClaudeUsageError.oauthFailed(
"Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy "
+ "is set to only prompt on user action. Open the CodexBar menu or click Refresh to retry.")
+ "is set to only prompt on user action. Click Refresh in the CodexBar menu to retry.")
}
}

Expand Down Expand Up @@ -401,7 +401,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
do {
if self.fetcher.oauthKeychainPromptCooldownEnabled {
switch delegatedOutcome {
case .skippedByCooldown, .cliUnavailable:
case .skippedByCooldown, .skippedByPromptPolicy, .cliUnavailable:
throw ClaudeUsageError.oauthFailed(
"Claude OAuth token expired; delegated refresh is unavailable (outcome="
+ "\(ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome))).")
Expand Down Expand Up @@ -925,6 +925,8 @@ extension ClaudeUsageFetcher {
switch outcome {
case .skippedByCooldown:
"skippedByCooldown"
case .skippedByPromptPolicy:
"skippedByPromptPolicy"
case .cliUnavailable:
"cliUnavailable"
case .attemptedSucceeded:
Expand All @@ -948,6 +950,9 @@ extension ClaudeUsageFetcher {
case .skippedByCooldown:
return "Claude OAuth token expired and delegated refresh is cooling down. "
+ "Please retry shortly, or run `claude login`."
case .skippedByPromptPolicy:
return "Claude OAuth token expired; background refresh is disabled by the Keychain prompt policy. "
+ "Refresh CodexBar manually or run `claude login`."
case .cliUnavailable:
return "Claude OAuth token expired and Claude CLI is not available for delegated refresh. "
+ "Install/configure `claude`, or run `claude login`."
Expand Down
154 changes: 114 additions & 40 deletions Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ struct ClaudeBaselineCharacterizationTests {
}
}

private func withBackgroundKeychainAccess<T>(operation: () async throws -> T) async rethrows -> T {
try await KeychainAccessGate.withTaskOverrideForTesting(false) {
try await operation()
}
}

@Test
func `app auto pipeline order is OAuth then CLI then web`() async {
let settings = ProviderSettingsSnapshot.make(claude: .init(
Expand Down Expand Up @@ -201,17 +207,77 @@ struct ClaudeBaselineCharacterizationTests {
let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog)
let env = ["CLAUDE_CLI_PATH": stubCLIPath]

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", "claude.web"])
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, false])
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", "claude.web"])
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, false])
}
}
}

let invocations = try String(contentsOf: invocationLog, encoding: .utf8)
#expect(invocations == "auth status --json\n")
}

@Test
func `app background auto does not launch Claude CLI under user action prompt policy`() 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(invocationLog: invocationLog)
let env = ["CLAUDE_CLI_PATH": stubCLIPath]

await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
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", "claude.web"])
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, false])
}
}

#expect(!FileManager.default.fileExists(atPath: invocationLog.path))
}

@Test
func `app background auto does not launch Claude CLI when Keychain access is disabled`() 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(invocationLog: invocationLog)
let env = ["CLAUDE_CLI_PATH": stubCLIPath]

await KeychainAccessGate.withTaskOverrideForTesting(true) {
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", "claude.web"])
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, false])
}
}
}

#expect(!FileManager.default.fileExists(atPath: invocationLog.path))
}

@Test(arguments: ["nonzero", "timeout", "malformed"])
func `app background auto falls back to web when auth status is unusable`(
failureMode: String) async throws
Expand Down Expand Up @@ -251,9 +317,13 @@ struct ClaudeBaselineCharacterizationTests {
rawText: nil)
}

let outcome = await self.withNoOAuthCredentials {
await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) {
await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
let outcome = await self.withBackgroundKeychainAccess {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
await self.withNoOAuthCredentials {
await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) {
await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
}
}
}
}
let result = try outcome.result.get()
Expand Down Expand Up @@ -333,39 +403,43 @@ struct ClaudeBaselineCharacterizationTests {
let stubCLIPath = try self.makeStubClaudeCLI()
let env = ["CLAUDE_CLI_PATH": stubCLIPath]

await self.withNoOAuthCredentials {
let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws
-> ClaudeStatusSnapshot = { binary, _, _ in
#expect(binary == stubCLIPath)
return 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")
}
let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) {
await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
}
await self.withBackgroundKeychainAccess {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
await self.withNoOAuthCredentials {
let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws
-> ClaudeStatusSnapshot = { binary, _, _ in
#expect(binary == stubCLIPath)
return 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")
}
let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) {
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) == [false, true])

switch outcome.result {
case let .success(result):
#expect(result.strategyID == "claude.cli")
#expect(result.sourceLabel == "claude")
#expect(result.usage.primary?.usedPercent == 12)
#expect(result.usage.secondary?.usedPercent == 40)
#expect(result.usage.tertiary?.usedPercent == 5)
#expect(result.usage.identity?.accountEmail == "user@example.com")
case let .failure(error):
Issue.record("Unexpected failure: \(error)")
#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"])
#expect(outcome.attempts.map(\.wasAvailable) == [false, true])

switch outcome.result {
case let .success(result):
#expect(result.strategyID == "claude.cli")
#expect(result.sourceLabel == "claude")
#expect(result.usage.primary?.usedPercent == 12)
#expect(result.usage.secondary?.usedPercent == 40)
#expect(result.usage.tertiary?.usedPercent == 5)
#expect(result.usage.identity?.accountEmail == "user@example.com")
case let .failure(error):
Issue.record("Unexpected failure: \(error)")
}
}
}
}
}
Expand Down
Loading
Loading