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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -613,15 +613,20 @@ 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 }
Comment on lines +616 to +618

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require a usable no-UI Keychain read in Never mode

When the stored mode is Never, this guard now lets the method continue, but the status switch below still treats errSecInteractionNotAllowed as success. ClaudeProviderDescriptor.isPlausiblyAvailable relies on this method before fetching, so a Claude Keychain item that requires UI is now advertised as available even though the later credential load runs with allowKeychainPrompt: false and cannot read it. In Never mode, verify the secret can be loaded noninteractively or treat interaction-required results as unavailable.

Useful? React with 👍 / 👎.

if ClaudeOAuthCredentialsStore.loadFromClaudeKeychainViaSecurityCLIIfEnabled(
interaction: ProviderInteractionContext.current) != nil
{
return true
}

let fallbackPromptMode = ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode()
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(mode: fallbackPromptMode) else {
guard ClaudeOAuthCredentialsStore.shouldAllowClaudeCodeKeychainAccess(
mode: fallbackPromptMode,
allowKeychainPrompt: false)
else {
return false
}
if ProviderInteractionContext.current == .background,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Comment on lines +795 to +796

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bypass prompt cooldown for Never-mode no-UI repair

This opens Never mode to the repair path, but the existing cooldown check below still runs for normal background loads because loadRecord derives respectKeychainPromptCooldown from !allowKeychainPrompt. If a user has a persisted denied-prompt cooldown and then relies on Never-mode no-UI repair, shouldAllowPrompt is false and the repair returns nil even though the subsequent Keychain reads are KeychainNoUIQuery reads that cannot show a prompt. Skip the prompt cooldown for the .never/noninteractive repair case.

Useful? React with 👍 / 👎.


if ClaudeOAuthCredentialsStore.shouldShowClaudeKeychainPreAlert() {
return nil
Expand Down Expand Up @@ -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),
Expand All @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1970,7 +1985,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()
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ struct ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests {
environment: environment)
}
}
#expect(isMcpOnly)
#expect(!isMcpOnly)

let blockedViaSecurityFramework = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down Expand Up @@ -686,7 +691,7 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests {
])
}
}
#expect(isMcpOnly)
#expect(!isMcpOnly)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests {
}
}

#expect(hasCredentials == true)
#expect(hasCredentials == false)
}

@Test
Expand Down
Loading