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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- Add `allowKeychainPrompt` parameter to skip prompts during background refreshes and availability checks.
- Add silent keychain access using `LAContext.interactionNotAllowed` to avoid unexpected prompts.
- Cache credentials in CodexBar's own keychain after first successful access, avoiding repeated prompts to Claude Code's keychain.
- Re-migrate Claude credentials when they change to maintain proper accessibility settings.
- Avoid mutating Claude Code's keychain item during startup migration.

## 0.18.0-beta.2 — 2026-01-21
### Highlights
Expand Down
81 changes: 1 addition & 80 deletions Sources/CodexBar/KeychainMigration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@ import CodexBarCore
import Foundation
import Security

import LocalAuthentication

/// Migrates keychain items to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
/// to prevent permission prompts on every rebuild during development.
enum KeychainMigration {
private static let log = CodexBarLog.logger(LogCategories.keychainMigration)
private static let migrationKey = "KeychainMigrationV1Completed"
private static let claudeMigrationKey = "KeychainMigrationClaudeCredentialsV1"


struct MigrationItem: Hashable, Sendable {
let service: String
Expand All @@ -33,10 +29,9 @@ enum KeychainMigration {
MigrationItem(service: "com.steipete.CodexBar", account: "copilot-api-token"),
MigrationItem(service: "com.steipete.CodexBar", account: "zai-api-token"),
MigrationItem(service: "com.steipete.CodexBar", account: "synthetic-api-key"),
MigrationItem(service: "Claude Code-credentials", account: nil),
]

/// Run migration once per installation (with a Claude-specific follow-up when credentials change).
/// Run migration once per installation
static func migrateIfNeeded() {
guard !KeychainAccessGate.isDisabled else {
self.log.info("Keychain access disabled; skipping migration")
Expand Down Expand Up @@ -69,31 +64,6 @@ enum KeychainMigration {
} else {
self.log.debug("Keychain migration already completed, skipping")
}

self.migrateClaudeCredentialsIfNeeded()
}

static func migrateClaudeCredentialsIfNeeded() {
guard let item = self.itemsToMigrate.first(where: { $0.service == "Claude Code-credentials" }) else {
return
}
guard let fingerprint = self.claudeCredentialsFingerprint() else { return }

let stored = self.loadClaudeMigrationFingerprint()
if stored == fingerprint { return }

do {
if try self.migrateItem(item) {
self.log.info("Migrated Claude credentials to ThisDeviceOnly")
} else {
self.log.debug("Claude credentials already using correct accessibility")
}
} catch {
self.log.error("Failed to migrate Claude credentials: \(String(describing: error))")
return
}

self.saveClaudeMigrationFingerprint(fingerprint)
}

/// Migrate a single keychain item to the new accessibility level
Expand Down Expand Up @@ -170,55 +140,6 @@ enum KeychainMigration {
return true
}

private struct ClaudeCredentialsFingerprint: Codable, Equatable {
let modifiedAt: Int?
let accessible: String?
}

private static func claudeCredentialsFingerprint() -> ClaudeCredentialsFingerprint? {
#if os(macOS)
if KeychainAccessGate.isDisabled { return nil }
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "Claude Code-credentials",
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecUseAuthenticationContext as String: context,
]

var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let attrs = result as? [String: Any] else {
return nil
}
let modifiedAt = (attrs[kSecAttrModificationDate as String] as? Date)
.map { Int($0.timeIntervalSince1970) }
let accessible = attrs[kSecAttrAccessible as String] as? String
return ClaudeCredentialsFingerprint(modifiedAt: modifiedAt, accessible: accessible)
#else
return nil
#endif
}

private static func loadClaudeMigrationFingerprint() -> ClaudeCredentialsFingerprint? {
guard let data = UserDefaults.standard.data(forKey: self.claudeMigrationKey) else { return nil }
return try? JSONDecoder().decode(ClaudeCredentialsFingerprint.self, from: data)
}

private static func saveClaudeMigrationFingerprint(_ fingerprint: ClaudeCredentialsFingerprint) {
guard let data = try? JSONEncoder().encode(fingerprint) else { return }
UserDefaults.standard.set(data, forKey: self.claudeMigrationKey)
}

#if DEBUG
static func _resetClaudeMigrationTrackingForTesting() {
UserDefaults.standard.removeObject(forKey: self.claudeMigrationKey)
UserDefaults.standard.removeObject(forKey: self.migrationKey)
}
#endif

/// Reset migration flag (for testing)
static func resetMigrationFlag() {
UserDefaults.standard.removeObject(forKey: self.migrationKey)
Expand Down
9 changes: 5 additions & 4 deletions Sources/CodexBar/UsageStore+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ extension UsageStore {

let outcome = await spec.fetch()
if provider == .claude,
ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
{
await MainActor.run {
self.snapshots.removeValue(forKey: .claude)
Expand All @@ -55,6 +55,7 @@ extension UsageStore {
self.tokenSnapshots.removeValue(forKey: .claude)
self.tokenErrors[.claude] = nil
self.failureGates[.claude]?.reset()
self.tokenFailureGates[.claude]?.reset()
self.lastTokenFetchAt.removeValue(forKey: .claude)
}
}
Expand All @@ -63,7 +64,7 @@ extension UsageStore {
}

switch outcome.result {
case .success(let result):
case let .success(result):
let scoped = result.usage.scoped(to: provider)
await MainActor.run {
self.handleSessionQuotaTransition(provider: provider, snapshot: scoped)
Expand All @@ -77,12 +78,12 @@ extension UsageStore {
provider: provider, settings: self.settings, store: self)
runtime.providerDidRefresh(context: context, provider: provider)
}
case .failure(let error):
case let .failure(error):
await MainActor.run {
let hadPriorData = self.snapshots[provider] != nil
let shouldSurface =
self.failureGates[provider]?
.shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
.shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
if shouldSurface {
self.errors[provider] = error.localizedDescription
self.snapshots.removeValue(forKey: provider)
Expand Down
11 changes: 10 additions & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1252,16 +1252,25 @@ extension UsageStore {
}
// Don't prompt for keychain access during debug dump
let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load(allowKeychainPrompt: false)) != nil
let hasClaudeBinary = BinaryLocator.resolveClaudeBinary(
env: ProcessInfo.processInfo.environment,
loginPATH: LoginShellPathCache.shared.current) != nil

let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
selectedDataSource: claudeUsageDataSource,
webExtrasEnabled: claudeWebExtrasEnabled,
hasWebSession: hasKey,
hasOAuthCredentials: hasOAuthCredentials)

lines.append("strategy=\(strategy.dataSource.rawValue)")
if claudeUsageDataSource == .auto {
lines.append("pipeline_order=web→cli→oauth")
lines.append("auto_heuristic=\(strategy.dataSource.rawValue)")
} else {
lines.append("strategy=\(strategy.dataSource.rawValue)")
}
lines.append("hasSessionKey=\(hasKey)")
lines.append("hasOAuthCredentials=\(hasOAuthCredentials)")
lines.append("hasClaudeBinary=\(hasClaudeBinary)")
if strategy.useWebExtras {
lines.append("web_extras=enabled")
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBarCore/BrowserCookieAccessGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ public enum BrowserCookieAccessGate {
])
}

public static func resetForTesting() {
self.lock.withLock { state in
state.loaded = true
state.deniedUntilByBrowser.removeAll()
UserDefaults.standard.removeObject(forKey: self.defaultsKey)
}
}

private static func chromiumKeychainRequiresInteraction() -> Bool {
for label in self.safeStorageLabels {
switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) {
Expand Down Expand Up @@ -104,5 +112,6 @@ public enum BrowserCookieAccessGate {

public static func recordIfNeeded(_ error: Error, now: Date = Date()) {}
public static func recordDenied(for browser: Browser, now: Date = Date()) {}
public static func resetForTesting() {}
}
#endif
4 changes: 1 addition & 3 deletions Sources/CodexBarCore/KeychainAccessPreflight.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,13 @@ public enum KeychainAccessPreflight {
public static func checkGenericPassword(service: String, account: String?) -> Outcome {
#if os(macOS)
guard !KeychainAccessGate.isDisabled else { return .notFound }
let context = LAContext()
context.interactionNotAllowed = true
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context,
]
KeychainNoUIQuery.apply(to: &query)
if let account {
query[kSecAttrAccount as String] = account
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/CodexBarCore/KeychainNoUIQuery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

#if os(macOS)
import LocalAuthentication
import Security

enum KeychainNoUIQuery {
static func apply(to query: inout [String: Any]) {
let context = LAContext()
context.interactionNotAllowed = true
query[kSecUseAuthenticationContext as String] = context

// NOTE: While Apple recommends using LAContext.interactionNotAllowed, that alone is not sufficient to
// prevent the legacy keychain "Allow/Deny" prompt on some configurations. We also set the UI policy to fail
// so SecItemCopyMatching returns errSecInteractionNotAllowed instead of showing UI.
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
}
}
#endif
Loading