Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Sources/CodexBar/CookieHeaderStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring {
if case .interactionRequired = KeychainAccessPreflight
.checkGenericPassword(service: self.service, account: self.account)
{
KeychainPromptHandler.handler?(KeychainPromptContext(
KeychainPromptHandler.notify(KeychainPromptContext(
kind: self.promptKind,
service: self.service,
account: self.account))
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/CopilotTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring {
if case .interactionRequired = KeychainAccessPreflight
.checkGenericPassword(service: self.service, account: self.account)
{
KeychainPromptHandler.handler?(KeychainPromptContext(
KeychainPromptHandler.notify(KeychainPromptContext(
kind: .copilotToken,
service: self.service,
account: self.account))
Expand Down
29 changes: 27 additions & 2 deletions Sources/CodexBar/KeychainPromptCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ struct KeychainPromptAlertModel: Equatable {
let documentationURL: String
}

enum ClaudeOAuthPromptExplanationPreference {
static let userDefaultsKey = "claudeOAuthPromptExplanationEnabled"

static func isEnabled(in userDefaults: UserDefaults = .standard) -> Bool {
userDefaults.object(forKey: self.userDefaultsKey) as? Bool ?? true
}
}

@MainActor
private final class KeychainPromptLearnMoreTarget: NSObject {
private let documentationURL: String
Expand All @@ -80,7 +88,7 @@ enum KeychainPromptCoordinator {
"https://github.com/steipete/CodexBar/blob/main/docs/keychain-prompts.md"

static func install() {
KeychainPromptHandler.handler = { context in
KeychainPromptHandler.resultHandler = { context in
self.presentKeychainPrompt(context)
}
BrowserCookieKeychainPromptHandler.handler = { context in
Expand Down Expand Up @@ -116,10 +124,27 @@ enum KeychainPromptCoordinator {
&& !executableURL.pathComponents.contains(where: { $0.hasSuffix(".app") })
}

private static func presentKeychainPrompt(_ context: KeychainPromptContext) {
private static func presentKeychainPrompt(_ context: KeychainPromptContext) -> Bool {
guard self.shouldPresentExplanation(for: context) else {
self.log.info("Keychain prompt explanation suppressed", metadata: ["kind": "claudeOAuth"])
return false
}
let model = self.alertModel(for: context)
self.log.info("Keychain prompt requested", metadata: ["kind": "\(context.kind)"])
self.presentAlert(model)
return true
}

static func shouldPresentExplanation(
for context: KeychainPromptContext,
userDefaults: UserDefaults = .standard) -> Bool
{
switch context.kind {
case .claudeOAuth:
ClaudeOAuthPromptExplanationPreference.isEnabled(in: userDefaults)
default:
true
}
}

private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) {
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/KimiTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ struct KeychainKimiTokenStore: KimiTokenStoring {
if case .interactionRequired = KeychainAccessPreflight
.checkGenericPassword(service: self.service, account: self.account)
{
KeychainPromptHandler.handler?(KeychainPromptContext(
KeychainPromptHandler.notify(KeychainPromptContext(
kind: .kimiToken,
service: self.service,
account: self.account))
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/MiniMaxAPITokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ struct KeychainMiniMaxAPITokenStore: MiniMaxAPITokenStoring {
if case .interactionRequired = KeychainAccessPreflight
.checkGenericPassword(service: self.service, account: self.account)
{
KeychainPromptHandler.handler?(KeychainPromptContext(
KeychainPromptHandler.notify(KeychainPromptContext(
kind: .minimaxToken,
service: self.service,
account: self.account))
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/MiniMaxCookieStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ struct KeychainMiniMaxCookieStore: MiniMaxCookieStoring {
if case .interactionRequired = KeychainAccessPreflight
.checkGenericPassword(service: self.service, account: self.account)
{
KeychainPromptHandler.handler?(KeychainPromptContext(
KeychainPromptHandler.notify(KeychainPromptContext(
kind: .minimaxCookie,
service: self.service,
account: self.account))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
_ = settings.claudeCookieSource
_ = settings.claudeCookieHeader
_ = settings.claudeOAuthKeychainPromptMode
_ = settings.claudeOAuthPromptExplanationEnabled
_ = settings.claudeOAuthKeychainReadStrategy
_ = settings.claudeWebExtrasEnabled
_ = settings.claudeSwapEnabled
Expand Down Expand Up @@ -119,6 +120,19 @@ struct ClaudeProviderImplementation: ProviderImplementation {
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "claude-oauth-prompt-explanation",
title: "Show Keychain access explanation",
subtitle: "Shows CodexBar's explanation before macOS asks for Claude OAuth Keychain access. " +
"This does not change the macOS prompt or OAuth behavior.",
binding: context.boolBinding(\.claudeOAuthPromptExplanationEnabled),
statusText: nil,
actions: [],
isVisible: nil,
isEnabled: nil,
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "claude-swap-accounts",
title: "Read accounts from claude-swap",
Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,14 @@ extension SettingsStore {
}
}

var claudeOAuthPromptExplanationEnabled: Bool {
get { self.defaultsState.claudeOAuthPromptExplanationEnabled }
set {
self.defaultsState.claudeOAuthPromptExplanationEnabled = newValue
self.userDefaults.set(newValue, forKey: ClaudeOAuthPromptExplanationPreference.userDefaultsKey)
}
}

var claudeOAuthKeychainReadStrategy: ClaudeOAuthKeychainReadStrategy {
get {
guard let raw = self.defaultsState.claudeOAuthKeychainReadStrategyRaw else {
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,8 @@ extension SettingsStore {
let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false
let claudeOAuthKeychainReadStrategyRaw = Self.loadClaudeOAuthKeychainReadStrategyRaw(userDefaults: userDefaults)
let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode")
let claudeOAuthPromptExplanationEnabled = userDefaults.object(
forKey: ClaudeOAuthPromptExplanationPreference.userDefaultsKey) as? Bool ?? true
let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false
let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool
let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true
Expand Down Expand Up @@ -604,6 +606,7 @@ extension SettingsStore {
confettiOnWeeklyLimitResetsEnabled: confettiOnReset.weekly,
menuBarShowsHighestUsage: menuBarShowsHighestUsage,
claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw,
claudeOAuthPromptExplanationEnabled: claudeOAuthPromptExplanationEnabled,
claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw,
claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw,
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ struct SettingsDefaultsState {
var confettiOnWeeklyLimitResetsEnabled: Bool
var menuBarShowsHighestUsage: Bool
var claudeOAuthKeychainPromptModeRaw: String?
var claudeOAuthPromptExplanationEnabled: Bool
var claudeOAuthKeychainReadStrategyRaw: String?
var claudeWebExtrasEnabledRaw: Bool
var showOptionalCreditsAndExtraUsage: Bool
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/SyntheticTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ struct KeychainSyntheticTokenStore: SyntheticTokenStoring {
if case .interactionRequired = KeychainAccessPreflight
.checkGenericPassword(service: self.service, account: self.account)
{
KeychainPromptHandler.handler?(KeychainPromptContext(
KeychainPromptHandler.notify(KeychainPromptContext(
kind: .syntheticToken,
service: self.service,
account: self.account))
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/ZaiTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ struct KeychainZaiTokenStore: ZaiTokenStoring {
if case .interactionRequired = KeychainAccessPreflight
.checkGenericPassword(service: self.service, account: self.account)
{
KeychainPromptHandler.handler?(KeychainPromptContext(
KeychainPromptHandler.notify(KeychainPromptContext(
kind: .zaiToken,
service: self.service,
account: self.account))
Expand Down
55 changes: 47 additions & 8 deletions Sources/CodexBarCore/KeychainAccessPreflight.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,37 +36,49 @@ public struct KeychainPromptContext: Sendable {

public enum KeychainPromptHandler {
final class HandlerStore: @unchecked Sendable {
let handler: (KeychainPromptContext) -> Void
let handler: (KeychainPromptContext) -> Bool

init(handler: @escaping (KeychainPromptContext) -> Void) {
init(handler: @escaping (KeychainPromptContext) -> Bool) {
self.handler = handler
}
}

@TaskLocal private static var taskHandlerStore: HandlerStore?
/// Compatibility callback for clients that only need notification delivery.
public nonisolated(unsafe) static var handler: ((KeychainPromptContext) -> Void)?

/// Optional result-capable callback used when callers need to know whether a prompt was shown.
public nonisolated(unsafe) static var resultHandler: ((KeychainPromptContext) -> Bool)?

public static func notify(_ context: KeychainPromptContext) {
_ = self.notifyIfHandled(context)
}

@discardableResult
static func notifyIfHandled(_ context: KeychainPromptContext) -> Bool {
if let taskHandlerStore {
taskHandlerStore.handler(context)
return taskHandlerStore.handler(context)
}
if let handler {
handler(context)
return true
}
guard let handler else { return false }
handler(context)
return true
if let resultHandler { return resultHandler(context) }
return false
}

#if DEBUG
static func withHandlerForTesting<T>(
_ handler: ((KeychainPromptContext) -> Void)?,
operation: () throws -> T) rethrows -> T
{
try self.$taskHandlerStore.withValue(handler.map(HandlerStore.init(handler:))) {
let resultHandler = handler.map { callback in
{ context in
callback(context)
return true
}
}
return try self.$taskHandlerStore.withValue(resultHandler.map(HandlerStore.init(handler:))) {
try operation()
}
}
Expand All @@ -75,10 +87,37 @@ public enum KeychainPromptHandler {
_ handler: ((KeychainPromptContext) -> Void)?,
operation: () async throws -> T) async rethrows -> T
{
try await self.$taskHandlerStore.withValue(handler.map(HandlerStore.init(handler:))) {
let resultHandler = handler.map { callback in
{ context in
callback(context)
return true
}
}
return try await self.$taskHandlerStore.withValue(resultHandler.map(HandlerStore.init(handler:))) {
try await operation()
}
}

static func withResultHandlerForTesting<T>(
result: Bool,
operation: () throws -> T) rethrows -> T
{
let handler: (KeychainPromptContext) -> Bool = { _ in result }
return try self.$taskHandlerStore.withValue(HandlerStore(handler: handler)) {
try operation()
}
}

static func withResultHandlerForTesting<T>(
result: Bool,
operation: () async throws -> T) async rethrows -> T
{
let handler: (KeychainPromptContext) -> Bool = { _ in result }
return try await self.$taskHandlerStore.withValue(HandlerStore(handler: handler)) {
try await operation()
}
}

#endif
}

Expand Down
60 changes: 59 additions & 1 deletion Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar
@testable import CodexBarCore

@Suite(.serialized)
struct KeychainPromptCoordinatorTests {
@Test
func `detects raw SwiftPM debug executable`() {
Expand Down Expand Up @@ -65,4 +67,60 @@ struct KeychainPromptCoordinatorTests {
#expect(model.message.contains("fetch your Claude usage"))
#expect(model.learnMoreButtonTitle == "Learn More…")
}

@Test
func `Claude explanation follows its setting while other prompts remain unchanged`() throws {
let suite = "KeychainPromptCoordinatorTests-explanation"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)

let claudeContext = KeychainPromptContext(
kind: .claudeOAuth,
service: "Claude Code-credentials",
account: nil)
let codexContext = KeychainPromptContext(
kind: .codexCookie,
service: "Chrome Safe Storage",
account: nil)

#expect(KeychainPromptCoordinator.shouldPresentExplanation(for: claudeContext, userDefaults: defaults))
#expect(KeychainPromptCoordinator.shouldPresentExplanation(for: codexContext, userDefaults: defaults))

defaults.set(false, forKey: ClaudeOAuthPromptExplanationPreference.userDefaultsKey)
#expect(!KeychainPromptCoordinator.shouldPresentExplanation(for: claudeContext, userDefaults: defaults))
}

@Test
func `prompt handler result reports whether an explanation was shown`() {
let context = KeychainPromptContext(
kind: .claudeOAuth,
service: "Claude Code-credentials",
account: nil)

let result = KeychainPromptHandler.withResultHandlerForTesting(result: false) {
KeychainPromptHandler.notifyIfHandled(context)
}

#expect(!result)
}

@Test
func `legacy prompt handler takes precedence over result handler`() {
let context = KeychainPromptContext(
kind: .claudeOAuth,
service: "Claude Code-credentials",
account: nil)

let previousHandler = KeychainPromptHandler.handler
let previousResultHandler = KeychainPromptHandler.resultHandler
defer {
KeychainPromptHandler.handler = previousHandler
KeychainPromptHandler.resultHandler = previousResultHandler
}
KeychainPromptHandler.handler = { _ in }
KeychainPromptHandler.resultHandler = { _ in false }
let result = KeychainPromptHandler.notifyIfHandled(context)

#expect(result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2282,7 +2282,7 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
AllowedProviderConstruct(
path: "Sources/CodexBar/SettingsStore.swift",
line: 1020,
line: 1023,
anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {",
expectedProviderIDs: ["factory", "minimax", "zai"],
expectedReferenceCount: 8,
Expand Down
22 changes: 22 additions & 0 deletions Tests/CodexBarTests/SettingsStoreCoverageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,28 @@ struct SettingsStoreCoverageTests {
#expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction)
}

@Test
func `claude keychain prompt explanation preserves the existing default`() {
let settings = Self.makeSettingsStore()
#expect(settings.claudeOAuthPromptExplanationEnabled)
}

@Test
func `claude keychain prompt explanation persists across store reload`() throws {
let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-explanation"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
let configStore = testConfigStore(suiteName: suite)

let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
first.claudeOAuthPromptExplanationEnabled = true
#expect(
defaults.bool(forKey: ClaudeOAuthPromptExplanationPreference.userDefaultsKey))

let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore)
#expect(second.claudeOAuthPromptExplanationEnabled)
}

@Test
func `claude keychain prompt mode persists across store reload`() throws {
let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode"
Expand Down
3 changes: 3 additions & 0 deletions docs/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ Admin API key setup:
- `Only on user action` (default): interactive prompts are reserved for user-initiated repair flows.
- `Always allow prompts`: allows interactive prompts in both user and background flows.
- This setting only affects Claude OAuth Keychain prompting behavior; it does not switch your Claude usage source.
- Preferences → Providers → Claude → Show Keychain access explanation controls CodexBar's explanatory alert before
the native macOS Keychain prompt. It remains enabled by default for existing installations; disable it when you
no longer need the explanation. This does not change Keychain authorization or OAuth behavior.
- If Preferences → Advanced → Disable Keychain access is enabled, this policy remains visible but inactive until
Keychain access is re-enabled.

Expand Down
Loading