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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
### Providers & Usage
- Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael!

### Fixes
- Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98!

## 0.21 — 2026-04-18

### Highlights
Expand Down
27 changes: 22 additions & 5 deletions Sources/CodexBarCore/KeychainCacheStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ public enum KeychainCacheStore {
return testResult
}
#if os(macOS)
let query: [String: Any] = [
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: self.serviceName,
kSecAttrAccount as String: key.account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
KeychainNoUIQuery.apply(to: &query)

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
Expand All @@ -68,11 +69,8 @@ public enum KeychainCacheStore {
return .invalid
}
return .found(decoded)
case errSecItemNotFound:
return .missing
default:
self.log.error("Keychain cache read failed (\(key.account)): \(status)")
return .invalid
return self.loadResultForKeychainReadFailure(status: status, key: key)
}
#else
return .missing
Expand Down Expand Up @@ -204,6 +202,25 @@ public enum KeychainCacheStore {
return decoder
}

#if os(macOS)
static func loadResultForKeychainReadFailure<Entry>(
status: OSStatus,
key: Key) -> LoadResult<Entry>
{
switch status {
case errSecItemNotFound:
return .missing
case errSecInteractionNotAllowed:
// Keychain is temporarily locked, e.g. immediately after wake from sleep.
self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access")
return .missing
default:
self.log.error("Keychain cache read failed (\(key.account)): \(status)")
return .invalid
}
}
#endif

private static func loadFromTestStore<Entry: Codable>(
key: Key,
as type: Entry.Type) -> LoadResult<Entry>?
Expand Down
17 changes: 17 additions & 0 deletions Tests/CodexBarTests/KeychainCacheStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,21 @@ struct KeychainCacheStoreTests {
#expect(Bool(false), "Expected keychain cache entry to be cleared")
}
}

#if os(macOS)
@Test
func `interaction not allowed is treated as temporarily missing`() {
let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString)
let result: KeychainCacheStore.LoadResult<TestEntry> = KeychainCacheStore.loadResultForKeychainReadFailure(
status: errSecInteractionNotAllowed,
key: key)

switch result {
case .missing:
#expect(true)
case .found, .invalid:
#expect(Bool(false), "Expected temporary keychain lock to preserve cache")
}
}
#endif
}