diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift index 76df9cb2c6..8ae11f3d20 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift @@ -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) + 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 { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index 15a5c42bcf..ebb8f287c4 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -19,6 +19,7 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { public enum Outcome: Sendable, Equatable { case skippedByCooldown + case skippedByPromptPolicy case cliUnavailable case attemptedSucceeded case attemptedFailed(String) @@ -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 @@ -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? @@ -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) { @@ -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 + { + 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 diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 1868f5c6e3..90a121e8e9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -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.storedMode() == .always + else { + return false + } return !Self.hasMcpOAuthOnlyClaudeKeychainPayload(environment: environment) case .environment: return sourceMode != .auto @@ -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.storedMode() == .always + 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) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 1bfd3aa780..eaa9a281cc 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -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.") } } @@ -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))).") @@ -925,6 +925,8 @@ extension ClaudeUsageFetcher { switch outcome { case .skippedByCooldown: "skippedByCooldown" + case .skippedByPromptPolicy: + "skippedByPromptPolicy" case .cliUnavailable: "cliUnavailable" case .attemptedSucceeded: @@ -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`." diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index d63b141fd8..afc2d07b1f 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -108,6 +108,12 @@ struct ClaudeBaselineCharacterizationTests { } } + private func withBackgroundKeychainAccess(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( @@ -201,17 +207,83 @@ 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 honors stored user action policy with experimental reader`() 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 ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityCLIExperimental) { + 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 @@ -251,9 +323,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() @@ -333,39 +409,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)") + } + } } } } diff --git a/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift b/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift new file mode 100644 index 0000000000..6add89699d --- /dev/null +++ b/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeKeychainLiveProofTests { + private static var isEnabled: Bool { + ProcessInfo.processInfo.environment["LIVE_CLAUDE_KEYCHAIN_PROOF"] == "1" + } + + @Test + func `live background Auto skips the opaque Claude Keychain boundary`() async { + guard Self.isEnabled else { return } + let mode = ClaudeOAuthKeychainPromptPreference.storedMode() + guard mode == .onlyOnUserAction || mode == .never else { + Issue.record("Live proof requires a restrictive stored Claude Keychain prompt mode; found \(mode.rawValue)") + return + } + + let outcome = await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt(timeout: 8) + } + } + + #expect(outcome == .skippedByPromptPolicy) + } + + @Test + func `live explicit user auth probe reports Claude login`() async throws { + guard Self.isEnabled else { return } + let binary = try #require(TTYCommandRunner.which("claude")) + + let isLoggedIn = await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeCLIAuthStatusProbe.isLoggedIn( + binary: binary, + environment: ProcessInfo.processInfo.environment, + timeout: 8) + } + + #expect(isLoggedIn) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift index 36ea589e94..0c129f2291 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreMCPOnlyGuardTests.swift @@ -6,7 +6,7 @@ import Testing @Suite(.serialized) struct ClaudeOAuthCredentialsStoreMCPOnlyGuardTests { @Test - func `standard reader blocks expired CLI owner in background but preserves user refresh`() async throws { + func `standard reader skips MCP keychain probe in background but preserves user refresh`() async throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" let mcpOAuthOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8) let tempDir = FileManager.default.temporaryDirectory @@ -37,7 +37,17 @@ struct ClaudeOAuthCredentialsStoreMCPOnlyGuardTests { keychainAccessDisabled: false, environment: [:]) } - #expect(isMcpOnly) + #expect(!isMcpOnly) + + let userInitiatedIsMcpOnly = ProviderInteractionContext.$current + .withValue(.userInitiated) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .userInitiated, + readStrategy: .securityFramework, + keychainAccessDisabled: false, + environment: [:]) + } + #expect(userInitiatedIsMcpOnly) ClaudeOAuthCredentialsStore.invalidateCache() let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) @@ -54,10 +64,10 @@ struct ClaudeOAuthCredentialsStoreMCPOnlyGuardTests { environment: [:], allowKeychainPrompt: false, respectKeychainPromptCooldown: true) - Issue.record("Expected MCP-only keychain failure") + Issue.record("Expected background refresh delegation") } catch let error as ClaudeOAuthCredentialsError { - guard case .mcpOAuthOnlyKeychain = error else { - Issue.record("Expected .mcpOAuthOnlyKeychain, got \(error)") + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") return } } catch { diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index fa3cb765e3..d138f763e3 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -2,6 +2,23 @@ import Foundation import Testing @testable import CodexBarCore +private final class ClaudeDelegatedTouchCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + self.lock.lock() + self.value += 1 + self.lock.unlock() + } + + func count() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } +} + @Suite(.serialized) struct ClaudeOAuthDelegatedRefreshCoordinatorTests { private enum StubError: Error, LocalizedError { @@ -32,20 +49,41 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { private func withCoordinatorOverrides( isolateState: Bool = true, cliAvailable: Bool? = nil, + promptMode: ClaudeOAuthKeychainPromptMode = .always, + keychainAccessDisabled: Bool = false, touchAuthPath: (@Sendable (TimeInterval, [String: String]) async throws -> Void)? = nil, keychainFingerprint: (@Sendable () -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?)? = nil, operation: () async throws -> T) async rethrows -> T { - if isolateState { - return try await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + try await KeychainAccessGate.withTaskOverrideForTesting(keychainAccessDisabled) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { + if isolateState { + return try await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting { + ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() + defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } + return try await ClaudeOAuthDelegatedRefreshCoordinator + .withKeychainFingerprintOverrideForTesting( + keychainFingerprint) + { + try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting( + cliAvailable) + { + try await ClaudeOAuthDelegatedRefreshCoordinator + .withTouchAuthPathOverrideForTesting( + touchAuthPath) + { + try await operation() + } + } + } + } + } ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting( keychainFingerprint) { - try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting( - cliAvailable) - { + try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(cliAvailable) { try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting( touchAuthPath) { @@ -55,19 +93,6 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { } } } - ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() - defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() } - return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting( - keychainFingerprint) - { - try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(cliAvailable) { - try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting( - touchAuthPath) - { - try await operation() - } - } - } } @Test @@ -124,6 +149,81 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { #expect(outcome == .cliUnavailable) } + @Test(arguments: [ + (ClaudeOAuthKeychainPromptMode.onlyOnUserAction, false), + (ClaudeOAuthKeychainPromptMode.never, false), + (ClaudeOAuthKeychainPromptMode.always, true), + ]) + func `background refresh never launches delegated Claude CLI without Keychain opt in`( + promptMode: ClaudeOAuthKeychainPromptMode, + keychainAccessDisabled: Bool) async + { + let touches = ClaudeDelegatedTouchCounter() + let outcome = await self.withCoordinatorOverrides( + cliAvailable: true, + promptMode: promptMode, + keychainAccessDisabled: keychainAccessDisabled, + touchAuthPath: { _, _ in touches.increment() }, + operation: { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 20001), + timeout: 0.1) + } + }) + + #expect(outcome == .skippedByPromptPolicy) + #expect(touches.count() == 0) + } + + @Test + func `opaque delegated CLI honors stored prompt mode when read strategy effective mode differs`() async { + let touches = ClaudeDelegatedTouchCounter() + let backgroundOutcome = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + #expect(ClaudeOAuthKeychainPromptPreference.effectiveMode() == .always) + return await self.withCoordinatorOverrides( + cliAvailable: true, + promptMode: .onlyOnUserAction, + touchAuthPath: { _, _ in touches.increment() }, + operation: { + await ProviderInteractionContext.$current.withValue(.background) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 20002), + timeout: 0.1) + } + }) + } + + #expect(backgroundOutcome == .skippedByPromptPolicy) + #expect(touches.count() == 0) + + let userOutcome = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + await self.withCoordinatorOverrides( + cliAvailable: true, + promptMode: .onlyOnUserAction, + touchAuthPath: { _, _ in touches.increment() }, + operation: { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(Data("stub".utf8))) { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 20003), + timeout: 0.1) + } + } + }) + } + + guard case .attemptedFailed = userOutcome else { + Issue.record("Expected explicit user refresh to launch the delegated CLI") + return + } + #expect(touches.count() == 1) + } + @Test func `successful auth touch reports attempted succeeded`() async { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() @@ -693,10 +793,7 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { } }) - guard case .attemptedFailed = outcome else { - Issue.record("Expected .attemptedFailed outcome") - return - } + #expect(outcome == .skippedByPromptPolicy) #expect(securityReadCounter.count < 1) } } diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index 751d1d6f63..aa2ef575d9 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -50,15 +50,21 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { } @Test - func `auto mode expired creds cli available returns available`() async { + func `auto mode expired CLI creds remain available after Keychain opt in`() async { let context = self.makeContext(sourceMode: .auto) let strategy = ClaudeOAuthFetchStrategy() - let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride - .withValue(self.expiredRecord()) { - await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { - await strategy.isAvailable(context) + let available = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await self.withAvailabilityKeychainDoubles { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { + await strategy.isAvailable(context) + } + } } } + } #expect(available == true) } @@ -93,24 +99,25 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { } @Test - func `auto mode keeps expired CLI credentials available with ordinary O auth keychain`() async { + func `stored user action policy blocks expired CLI credentials with experimental reader`() async { let available = await self.expiredCLIAvailability( sourceMode: .auto, interaction: .background, - keychainData: self.ordinaryOAuthKeychainPayload) + keychainData: self.ordinaryOAuthKeychainPayload, + readStrategy: .securityCLIExperimental) - #expect(available) + #expect(!available) } @Test - func `auto mode ignores MCP-only keychain when keychain access is disabled`() async { + func `auto mode disables expired Claude CLI credentials when keychain access is disabled`() async { let available = await self.expiredCLIAvailability( sourceMode: .auto, interaction: .background, keychainData: self.mcpOAuthOnlyKeychainPayload, keychainAccessDisabled: true) - #expect(available) + #expect(!available) } @Test @@ -264,10 +271,16 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { sourceMode: .auto, env: ["CLAUDE_CLI_PATH": cliURL.path]) let strategy = ClaudeOAuthFetchStrategy() - let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride - .withValue(self.expiredRecord()) { - await strategy.isAvailable(context) + let available = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await self.withAvailabilityKeychainDoubles { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride + .withValue(self.expiredRecord()) { + await strategy.isAvailable(context) + } + } } + } #expect(available == true) } @@ -445,7 +458,9 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { sourceMode: ProviderSourceMode, interaction: ProviderInteraction, keychainData: Data, - keychainAccessDisabled: Bool = false) async -> Bool + keychainAccessDisabled: Bool = false, + promptMode: ClaudeOAuthKeychainPromptMode = .onlyOnUserAction, + readStrategy: ClaudeOAuthKeychainReadStrategy = .securityFramework) async -> Bool { let context = self.makeContext(sourceMode: sourceMode) let strategy = ClaudeOAuthFetchStrategy() @@ -453,15 +468,18 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { .withValue(self.expiredRecord()) { await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { await KeychainAccessGate.withTaskOverrideForTesting(keychainAccessDisabled) { - await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { - await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: keychainData, - fingerprint: nil) - { - await ProviderInteractionContext.$current.withValue(interaction) { - await strategy.isAvailable(context) + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(promptMode) { + await ClaudeOAuthKeychainReadStrategyPreference + .withTaskOverrideForTesting(readStrategy) { + await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + await ProviderInteractionContext.$current.withValue(interaction) { + await strategy.isAvailable(context) + } + } } - } } } } diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index c3fdc31c59..fb9a66c203 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -317,6 +317,8 @@ struct ClaudeUsageTests { return } #expect(message.contains("background repair is suppressed")) + #expect(message.contains("Click Refresh in the CodexBar menu")) + #expect(!message.contains("Open the CodexBar menu or")) } catch { Issue.record("Expected ClaudeUsageError, got \(error)") } diff --git a/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift b/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift index 51ec219d36..94713480c1 100644 --- a/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift +++ b/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift @@ -71,14 +71,18 @@ struct ClaudeWebFetchDeadlineTests { let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in Self.makeClaudeStatus() } - let outcome = await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue( - availabilityOverride) - { - await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) { - await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { - await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( - context: context, - provider: .claude) + let outcome = await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue( + availabilityOverride) + { + await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) { + await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + } } } } diff --git a/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift b/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift index 1b3fad103d..21ca1b3707 100644 --- a/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift +++ b/TestsLinux/ClaudeOAuthDelegatedRefreshLinuxTests.swift @@ -65,6 +65,8 @@ struct ClaudeOAuthDelegatedRefreshLinuxTests { #expect(result.attempts == 0) #expect(result.message.contains("background repair is suppressed")) + #expect(result.message.contains("Click Refresh in the CodexBar menu")) + #expect(!result.message.contains("Open the CodexBar menu or")) } private func runDelegatedRefresh(