diff --git a/CHANGELOG.md b/CHANGELOG.md index 934dd2879b..f63372202c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - ZenMux: add Management API usage with five-hour and weekly quotas, subscription expiry, and USD PAYG balance. Thanks @kays0x! ### Fixed +- Claude: recover a missing credentials file from a valid Claude Code Keychain item without showing Keychain UI when Never prompt is selected (#1975). Thanks @OfficialAbhinavSingh! - Codex cost usage: invalidate cached fork totals when the parent session appears, changes, or resolves to a different file, preventing stale inherited baselines. Thanks @xx205! - Cursor: bind interactive account login to one readable browser, preserve the active session on cancellation or failure, and prevent background refreshes from replacing the selected account. Thanks @chapati23! - Menu bar: prevent duplicate provider items when usage updates re-enter initial status-item setup (#2162). Thanks @ss251! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift index 76df9cb2c6..11d0304efd 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift @@ -42,7 +42,8 @@ extension ClaudeOAuthCredentialsStore { } /// Attempts a Claude keychain read via `/usr/bin/security` when the experimental reader is enabled. - /// - Important: `interaction` is diagnostics context only and does not gate CLI execution. + /// - Important: `interaction` is diagnostics context only. The stored Never policy still blocks the CLI because + /// `security` can prompt. static func loadFromClaudeKeychainViaSecurityCLIIfEnabled( interaction: ProviderInteraction, readStrategy: ClaudeOAuthKeychainReadStrategy = ClaudeOAuthKeychainReadStrategyPreference.current()) @@ -92,6 +93,7 @@ extension ClaudeOAuthCredentialsStore { -> Data? { guard self.shouldPreferSecurityCLIKeychainRead(readStrategy: readStrategy) else { return nil } + guard ClaudeOAuthKeychainPromptPreference.storedMode() != .never else { return nil } let interactionMetadata = interaction == .userInitiated ? "user" : "background" do { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index cfd1b4bb7b..81ea4dbef3 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -613,7 +613,9 @@ public enum ClaudeOAuthCredentialsStore { self.context.run { #if os(macOS) let mode = ClaudeOAuthKeychainPromptPreference.current() - guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false } + guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess( + mode: mode, + allowKeychainPrompt: false) else { return false } if ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled( interaction: ProviderInteractionContext.current) != nil { @@ -621,7 +623,10 @@ public enum ClaudeOAuthCredentialsStore { } let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() - guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else { + guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess( + mode: fallbackPromptMode, + allowKeychainPrompt: false) + else { return false } if ProviderInteractionContext.current == .background, @@ -707,7 +712,8 @@ public enum ClaudeOAuthCredentialsStore { { #if os(macOS) let mode = ClaudeOAuthKeychainPromptPreference.current() - guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil } + guard ClaudeOAuthCredentialsStore + .shouldAllowClaudeCodeKeychainAccess(mode: mode, allowKeychainPrompt: false) else { return nil } if ClaudeOAuthCredentialsStore.isPromptPolicyApplicable, respectKeychainPromptCooldown, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) @@ -786,7 +792,8 @@ public enum ClaudeOAuthCredentialsStore { { #if os(macOS) let mode = ClaudeOAuthKeychainPromptPreference.current() - guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil } + guard ClaudeOAuthCredentialsStore + .shouldAllowClaudeCodeKeychainAccess(mode: mode, allowKeychainPrompt: false) else { return nil } if ClaudeOAuthCredentialsStore.shouldShowClaudeKeychainPreAlert() { return nil @@ -897,7 +904,9 @@ public enum ClaudeOAuthCredentialsStore { self.context.run { #if os(macOS) let mode = ClaudeOAuthKeychainPromptPreference.current() - guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false } + guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess( + mode: mode, + allowKeychainPrompt: false) else { return false } if let data = ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled( interaction: ProviderInteractionContext.current), @@ -916,7 +925,10 @@ public enum ClaudeOAuthCredentialsStore { } let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() - guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else { + guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess( + mode: fallbackPromptMode, + allowKeychainPrompt: false) + else { return false } @@ -1641,7 +1653,8 @@ public enum ClaudeOAuthCredentialsStore { return .value(override) } #endif - guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return .unavailable } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode, allowKeychainPrompt: false) + else { return .unavailable } if self.isPromptPolicyApplicable, ProviderInteractionContext.current == .background, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() @@ -1704,8 +1717,9 @@ public enum ClaudeOAuthCredentialsStore { return data } - // For experimental strategy, enforce stored prompt policy before any Security.framework fallback probes. - guard self.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else { return nil } + // For experimental strategy, apply the stored policy before no-UI Security.framework fallback probes. + guard self.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode, allowKeychainPrompt: false) + else { return nil } #if DEBUG if let store = taskClaudeKeychainOverrideStore { @@ -1901,7 +1915,8 @@ public enum ClaudeOAuthCredentialsStore { enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]> { if enforcePromptPolicy { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode, allowKeychainPrompt: false) + else { return .unavailable } if self.isPromptPolicyApplicable, ProviderInteractionContext.current == .background, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() @@ -1970,7 +1985,8 @@ public enum ClaudeOAuthCredentialsStore { enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe { if enforcePromptPolicy { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode, allowKeychainPrompt: false) + else { return .unavailable } if self.isPromptPolicyApplicable, ProviderInteractionContext.current == .background, !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() @@ -2028,7 +2044,8 @@ public enum ClaudeOAuthCredentialsStore { allowKeychainPrompt: Bool, promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) throws -> Data? { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode, allowKeychainPrompt: allowKeychainPrompt) + else { return nil } self.log.debug( "Claude keychain data read start", metadata: [ @@ -2090,7 +2107,8 @@ public enum ClaudeOAuthCredentialsStore { allowKeychainPrompt: Bool, promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) throws -> Data? { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode, allowKeychainPrompt: allowKeychainPrompt) + else { return nil } self.log.debug( "Claude keychain legacy data read start", metadata: [ @@ -2341,11 +2359,16 @@ public enum ClaudeOAuthCredentialsStore { } private static func shouldAllowClaudeCodeKeychainAccess( - mode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current()) -> Bool + mode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference.current(), + allowKeychainPrompt: Bool = true) -> Bool { guard self.keychainAccessAllowed else { return false } switch mode { - case .never: return false + case .never: + // `.never` means "no interactive prompts", not "no Keychain access at all": a guaranteed + // no-UI read (KeychainNoUIQuery) must still be able to repair a missing credentials file + // from a valid Keychain item without ever surfacing a system prompt. + return !allowKeychainPrompt case .onlyOnUserAction: return ProviderInteractionContext.current == .userInitiated || self.allowBackgroundPromptBootstrap case .always: return true @@ -2365,7 +2388,7 @@ public enum ClaudeOAuthCredentialsStore { #endif #if os(macOS) let mode = ClaudeOAuthKeychainPromptPreference.current() - guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode, allowKeychainPrompt: false) else { return nil } // Keep experimental mode prompt-safe: avoid Security.framework candidate probes when preflight says // interaction is likely. if self.shouldShowClaudeKeychainPreAlert() { @@ -2484,7 +2507,7 @@ extension ClaudeOAuthCredentialsStore { } #endif let mode = ClaudeOAuthKeychainPromptPreference.current() - guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode, allowKeychainPrompt: false) else { return false } return switch KeychainAccessPreflight.checkGenericPassword(service: self.claudeKeychainService, account: nil) { case .interactionRequired: true diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift index 64efad38f7..723b3cc61a 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift @@ -107,7 +107,7 @@ struct ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests { environment: environment) } } - #expect(isMcpOnly) + #expect(!isMcpOnly) let blockedViaSecurityFramework = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift index 8b486fcba7..d8e6cf4983 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift @@ -623,10 +623,15 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { { try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) { - try ProviderInteractionContext.$current.withValue(.background) { - try ClaudeOAuthCredentialsStore.load( - environment: [:], - allowKeychainPrompt: false) + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: securityData, + fingerprint: nil) + { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } } } } @@ -686,7 +691,7 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { ]) } } - #expect(isMcpOnly) + #expect(!isMcpOnly) } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift index eb9ff92609..4d12fc58ad 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -364,7 +364,7 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { } } - #expect(hasCredentials == true) + #expect(hasCredentials == false) } @Test diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 537333a8ff..e11193e1d1 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -911,3 +911,208 @@ struct ClaudeOAuthCredentialsStoreTests { #expect(forwarded == fingerprint) } } + +#if os(macOS) +extension ClaudeOAuthCredentialsStoreTests { + private func withMissingCredentialsFile(operation: () throws -> T) throws -> T { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + // Deliberately leave this URL empty: this is the missing-credentials-file bug trigger. + let fileURL = tempDirectory.appendingPathComponent("credentials.json") + return try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try operation() + } + } + + private func withIsolatedOAuthCache(operation: () throws -> T) throws -> T { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + return try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try KeychainAccessGate.withTaskOverrideForTesting(false) { + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try operation() + } + } + } + } + } + } + + @Test + func `never mode repairs a missing credentials file from a valid no-UI Keychain read`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + let keychainData = self.makeCredentialsData( + accessToken: "test-token-placeholder", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } + } + } + + #expect(record.credentials.accessToken == "test-token-placeholder") + #expect(record.source == .claudeKeychain) + #expect(record.owner == .claudeCLI) + } + } + } + + @Test + func `never mode skips the experimental security CLI before no-UI Keychain repair`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + let noUIData = self.makeCredentialsData( + accessToken: "test-token-placeholder", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let securityCLIData = self.makeCredentialsData( + accessToken: "decoy-token", + expiresAt: Date(timeIntervalSinceNow: 3600)) + final class ReadCounter: @unchecked Sendable { + var count = 0 + } + let securityCLIReads = ReadCounter() + + let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( + .dynamic { _ in + securityCLIReads.count += 1 + return securityCLIData + }) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: noUIData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } + } + } + } + } + + #expect(record.credentials.accessToken == "test-token-placeholder") + #expect(record.source == .claudeKeychain) + #expect(securityCLIReads.count < 1) + } + } + } + + @Test + func `never mode still blocks an interactive Keychain read even with a valid item present`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + let keychainData = self.makeCredentialsData( + accessToken: "test-token-placeholder", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: true) + } + } + } + } + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } + } + } + } + + @Test + func `never mode without any Keychain item still fails closed`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + // A registered empty override prevents any fallback to real SecItem probes. + let emptyKeychain = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore( + data: nil, + fingerprint: nil) + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore + .withMutableClaudeKeychainOverrideStoreForTesting(emptyKeychain) { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } + } + } + } + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } + } + } + } + + @Test + func `global Keychain disable blocks no-UI repair in never mode`() throws { + try self.withIsolatedOAuthCache { + try self.withMissingCredentialsFile { + let keychainData = self.makeCredentialsData( + accessToken: "test-token-placeholder", + expiresAt: Date(timeIntervalSinceNow: 3600)) + + let error = #expect(throws: ClaudeOAuthCredentialsError.self) { + try KeychainAccessGate.withTaskOverrideForTesting(true) { + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: false, + allowClaudeKeychainRepairWithoutPrompt: true) + } + } + } + } + } + } + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(String(describing: error))") + return + } + } + } + } +} +#endif