Merge pull request #1037 from avs-io/fix/menubar-keychain-credential-cache

fix(menubar): migrate Claude/Codex caches to namespaced Keychain
This commit is contained in:
Resham Joshi 2026-08-19 12:05:47 -07:00 committed by GitHub
commit 96fc940798
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1673 additions and 65 deletions

View file

@ -29,6 +29,7 @@
- **One rule for every cache file.** `CODEBURN_CACHE_DIR` when set, otherwise `~/.cache/codeburn`. `XDG_CACHE_HOME` is no longer consulted; the sync ledger, the only file that ever honored it, is merged into the canonical location on first read and the legacy copy is retired, so nothing is re-uploaded after the move. (#972)
### Fixed (Desktop & Menubar)
- **The menubar's copies of your Claude and Codex credentials move out of Application Support and into the login Keychain.** Connecting a provider used to leave the copied OAuth material in `~/Library/Application Support/CodeBurn/*-credentials.v1.json`, written world-readable (0644) because macOS ignores `.completeFileProtection` outside iOS. The copy now lives in a CodeBurn-owned login-Keychain item, and the first read after upgrading migrates the old file: it is reopened with `O_NOFOLLOW`, refused if it is a symlink or not owned by you, repaired to 0600 before a single secret byte is read, written to the Keychain, read back and compared, and only then unlinked — a failed or unverified write leaves the (now 0600) file in place so a retry can still find it, and the next read retries the cleanup. Where both a Keychain item and an old file exist, the one that expires later wins before anything is removed, so an item left behind by a much older build cannot displace a fresher token. Claude's entry no longer stores a refresh token at all — the CLI owns that grant and the menubar never spends it — and any refresh token in a historical blob is dropped on read. Disconnect only reports success once the material is actually gone; if the delete fails it says so and leaves the provider connected so you can retry. Keychain reads are non-interactive and are skipped outright while the login Keychain is locked, so a background quota refresh can never raise an unlock panel. (#1037)
- **First launch no longer asks to control System Events.** The macOS menubar registered its login item by driving System Events over AppleScript, which made macOS put up an Automation consent dialog the first time the app ran. It now registers itself through `SMAppService.mainApp`, an in-process call that needs no Automation grant; there is no AppleScript fallback, so a failure logs and leaves the login item unset rather than bringing the prompt back. The same `codeburn.loginItemRegistered` guard still limits this to the first launch, so a login item you removed by hand stays removed. (#1026)
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)

View file

@ -1053,7 +1053,7 @@ final class AppStore {
return false
} catch {
guard gen == claudeRefreshGen else { return false }
subscriptionError = sanitizeForUI(String(describing: error))
subscriptionError = sanitizeForUI(error.localizedDescription)
subscriptionLoadState = .failed
return false
}
@ -1064,11 +1064,18 @@ final class AppStore {
/// account or tier) starts clean. capacityEstimates and the snapshot store
/// would otherwise contaminate "Based on last cycle" projections.
func disconnectSubscription() {
ClaudeSubscriptionService.disconnect()
let result = ClaudeSubscriptionService.disconnect()
// Bump the generation token so any in-flight refreshSubscription that
// resumes after this point detects the disconnect and discards its
// result instead of re-populating the cleared state.
claudeRefreshGen &+= 1
guard result.isSuccess else {
// Nothing was removed, so nothing is disconnected. Leave the
// connected state exactly as it was the bootstrap flag is still
// set, Disconnect stays available, and the banner says to retry.
subscriptionError = "Could not fully remove the local Claude credential cache. Disconnect again to retry."
return
}
subscription = nil
subscriptionError = nil
subscriptionLoadState = .notBootstrapped
@ -1091,7 +1098,7 @@ final class AppStore {
} catch let err as CodexSubscriptionService.FetchError {
applyCodexFetchError(err)
} catch {
codexError = sanitizeForUI(String(describing: error))
codexError = sanitizeForUI(error.localizedDescription)
codexLoadState = .failed
}
}
@ -1124,15 +1131,21 @@ final class AppStore {
return false
} catch {
guard gen == codexRefreshGen else { return false }
codexError = sanitizeForUI(String(describing: error))
codexError = sanitizeForUI(error.localizedDescription)
codexLoadState = .failed
return false
}
}
func disconnectCodex() {
CodexSubscriptionService.disconnect()
let result = CodexSubscriptionService.disconnect()
codexRefreshGen &+= 1
guard result.isSuccess else {
// Nothing removed means nothing disconnected; keep state intact so
// Disconnect stays available for a retry.
codexError = "Could not fully remove the local Codex credential cache. Disconnect again to retry."
return
}
codexUsage = nil
codexError = nil
codexLoadState = .notBootstrapped
@ -1176,7 +1189,7 @@ final class AppStore {
applyKimiFetchError(err)
} catch {
guard gen == kimiRefreshGen else { return }
kimiError = sanitizeForUI(String(describing: error))
kimiError = sanitizeForUI(error.localizedDescription)
kimiLoadState = .failed
}
}
@ -1210,7 +1223,7 @@ final class AppStore {
return false
} catch {
guard gen == kimiRefreshGen else { return false }
kimiError = sanitizeForUI(String(describing: error))
kimiError = sanitizeForUI(error.localizedDescription)
kimiLoadState = .failed
return false
}

View file

@ -16,9 +16,9 @@ import Security
/// the refresh endpoint. If the CLI hasn't rotated yet we report a
/// transient staleness (`sourceTokenStale`) and recover on its next use.
///
/// 3. **In-memory + file cache** so back-to-back reads in the same refresh
/// 3. **In-memory + Keychain cache** so back-to-back reads in the same refresh
/// cycle don't re-hit the source, and we keep serving the last good token
/// across launches.
/// across launches without a plaintext Application Support file.
enum ClaudeCredentialStore {
private static let bootstrapCompletedKey = "codeburn.claude.bootstrapCompleted"
private static let inMemoryTTL: TimeInterval = 5 * 60
@ -28,12 +28,46 @@ enum ClaudeCredentialStore {
private static let credentialsRelativePath = ".claude/.credentials.json"
private static let maxCredentialBytes = 64 * 1024
/// Legacy local cache file. New writes use the macOS Keychain; this path is
/// Legacy local cache file under Application Support. Migration to the
/// CodeBurn-namespaced Keychain item is staged behind an injectable seam.
private static let cacheFilename = "claude-credentials.v1.json"
static let ourKeychainService = CodeBurnKeychainIdentity.claudeService
static let ourKeychainAccount = CodeBurnKeychainIdentity.account
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
// MARK: - Injectable seams (tests + staged Keychain migration)
/// Override Application Support root. Nil uses the real user domain.
nonisolated(unsafe) static var applicationSupportDirectoryOverride: URL?
/// Override home for Claude CLI credential discovery. Nil uses the real home.
nonisolated(unsafe) static var homeDirectoryOverride: URL?
/// Override defaults used for bootstrap flags.
nonisolated(unsafe) static var userDefaultsOverride: UserDefaults?
/// Keychain backend. Production uses Live; tests inject InMemory.
nonisolated(unsafe) static var keychainCache: any KeychainCredentialCaching = LiveKeychainCredentialCache()
static func resetTestSeams() {
applicationSupportDirectoryOverride = nil
homeDirectoryOverride = nil
userDefaultsOverride = nil
keychainCache = LiveKeychainCredentialCache()
lastCacheDeleteResult = nil
unlinkLegacyOverride = nil
tightenLegacyOverride = nil
lock.withLock { memoryCache = nil }
}
private static var defaults: UserDefaults {
userDefaultsOverride ?? .standard
}
private static var homeDirectory: URL {
homeDirectoryOverride ?? FileManager.default.homeDirectoryForCurrentUser
}
struct CachedRecord {
let record: CredentialRecord
let cachedAt: Date
@ -88,18 +122,42 @@ enum ClaudeCredentialStore {
/// True once the user has explicitly connected (clicked Connect in the Plan
/// tab AND we successfully read their credentials). Persists across launches.
static var isBootstrapCompleted: Bool {
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
get { defaults.bool(forKey: bootstrapCompletedKey) }
set { defaults.set(newValue, forKey: bootstrapCompletedKey) }
}
/// Reset bootstrap state. Used when the user explicitly wants to disconnect
/// or when the refresh token has been revoked terminally.
static func resetBootstrap() {
/// or when the refresh token has been revoked terminally. Deletion failures
/// are recorded on `lastCacheDeleteResult` callers must not claim the
/// local copy is gone when `isSuccess` is false.
@discardableResult
static func resetBootstrap() -> CacheDeleteResult {
lock.withLock { memoryCache = nil }
deleteOurCache()
isBootstrapCompleted = false
let result = deleteOurCache()
lastCacheDeleteResult = result
// A failed Keychain delete must not pretend the provider is disconnected.
// Clearing the flag hides Disconnect and orphans the remaining item.
if result.isSuccess {
isBootstrapCompleted = false
}
return result
}
/// Outcome of deleting CodeBurn-owned Claude cache material.
struct CacheDeleteResult: Equatable {
var keychainDeletedOrAbsent: Bool
var legacyDeletedOrAbsent: Bool
var isSuccess: Bool { keychainDeletedOrAbsent && legacyDeletedOrAbsent }
}
/// Last disconnect/cleanup result. Nil until the first delete attempt.
nonisolated(unsafe) static var lastCacheDeleteResult: CacheDeleteResult?
/// Test seam: force legacy unlink to throw so we can assert the 0600 repair.
nonisolated(unsafe) static var unlinkLegacyOverride: ((URL) throws -> Void)?
nonisolated(unsafe) static var tightenLegacyOverride: ((URL) throws -> Void)?
// MARK: - Public API
/// User-initiated entry point. Reads from Claude's source (PROMPTS for the
@ -124,7 +182,19 @@ enum ClaudeCredentialStore {
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
return cached.record
}
if let stored = try readOurCache() {
let fetched: CredentialRecord?
do {
fetched = try readOurCache()
} catch let err as KeychainCredentialCacheError {
// A locked/denied keychain means "can't look right now", not "the
// item is gone". Serve the last known token and leave the bootstrap
// flag alone so we don't silently disconnect the user.
if case .unavailable = err {
return lock.withLock { memoryCache }?.record
}
throw err
}
if let stored = fetched {
cacheInMemory(stored)
return stored
}
@ -190,7 +260,7 @@ enum ClaudeCredentialStore {
}
private static func readClaudeFile() throws -> CredentialRecord? {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
let url = homeDirectory.appendingPathComponent(credentialsRelativePath)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
return try parseClaudeBlob(data: sanitizeClaudeBlob(data))
@ -305,37 +375,207 @@ enum ClaudeCredentialStore {
}
}
// MARK: - Local cache file (no keychain involvement)
// MARK: - Local cache (injectable Application Support + Keychain seam)
private static func cacheFileURL() -> URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
static func cacheFileURL() -> URL {
let support = applicationSupportDirectoryOverride
?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? homeDirectory.appendingPathComponent("Library/Application Support")
return support
.appendingPathComponent("CodeBurn", isDirectory: true)
.appendingPathComponent(cacheFilename)
}
/// Production-used write path. Persists to the CodeBurn-namespaced Keychain
/// item. Claude never stores a refresh token in this cache. After a verified
/// Keychain read-back, any legacy JSON is unlinked (not "securely erased").
static func writeOurCache(record: CredentialRecord) throws {
let persisted = encodePersisted(record)
let data = try JSONEncoder().encode(persisted)
try keychainCache.upsert(
service: ourKeychainService,
account: ourKeychainAccount,
data: data
)
try verifyKeychainMatches(persisted)
tryUnlinkLegacyAfterVerifiedKeychain()
}
/// Cache shape stored in Keychain intentionally omits refreshToken.
struct PersistedCacheRecord: Codable, Equatable {
let accessToken: String
let expiresAt: Date?
let rateLimitTier: String?
}
private static func encodePersisted(_ record: CredentialRecord) -> PersistedCacheRecord {
PersistedCacheRecord(
accessToken: record.accessToken,
expiresAt: record.expiresAt,
rateLimitTier: record.rateLimitTier
)
}
private static func decodePersisted(_ data: Data) -> CredentialRecord? {
if let persisted = try? JSONDecoder().decode(PersistedCacheRecord.self, from: data) {
return CredentialRecord(
accessToken: persisted.accessToken,
refreshToken: nil,
expiresAt: persisted.expiresAt,
rateLimitTier: persisted.rateLimitTier
)
}
// Historical blobs may still include refreshToken; drop it on read.
if let legacy = try? JSONDecoder().decode(CredentialRecord.self, from: data) {
return CredentialRecord(
accessToken: legacy.accessToken,
refreshToken: nil,
expiresAt: legacy.expiresAt,
rateLimitTier: legacy.rateLimitTier
)
}
return nil
}
private static func verifyKeychainMatches(_ expected: PersistedCacheRecord) throws {
guard let data = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount),
let roundTrip = decodePersisted(data),
encodePersisted(roundTrip) == expected
else {
throw StoreError.keychainWriteFailed(-1)
}
}
/// Serializes migrate + unlink across processes so two menubar instances (or the
/// CLI) cannot race on the same legacy file. Only `SafeFile.Error` from acquiring
/// the lock is tolerated `readOurCacheLocked` never lets one escape, because
/// `readLegacyFile` swallows its own read failures.
private static func readOurCache() throws -> CredentialRecord? {
do {
return try SafeFile.withExclusiveLock(at: cacheFileURL().path + ".lock") {
try readOurCacheLocked()
}
} catch is SafeFile.Error {
return try readOurCacheLocked()
}
}
private static func readOurCacheLocked() throws -> CredentialRecord? {
if let data = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount),
let record = decodePersisted(data) {
// The Keychain item can predate the legacy file by a long way this
// service name has been in use since May 2026, so an upgrading install
// can hold a months-old item beside a file the old build wrote today.
// Adopt whichever expires later before unlinking anything.
if let fresher = legacyRecordIfNewer(than: record) {
try? writeOurCache(record: fresher)
return fresher
}
// Rewrite historical Claude blobs once without refreshToken.
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
object.keys.contains("refreshToken") {
try? writeOurCache(record: record)
} else {
tryUnlinkLegacyAfterVerifiedKeychain()
}
return record
}
return try migrateLegacyFileIfPresent()
}
/// Reads the legacy file only to compare recency. Returns it when it expires
/// strictly later than `record`; nil when absent, unreadable, or not newer.
private static func legacyRecordIfNewer(than record: CredentialRecord) -> CredentialRecord? {
guard let legacy = readLegacyFile() else { return nil }
guard let legacyExpiry = legacy.expiresAt else { return nil }
guard let currentExpiry = record.expiresAt else { return legacy }
return legacyExpiry > currentExpiry ? legacy : nil
}
/// Secure-read + decode the legacy JSON, dropping any refreshToken it holds.
/// Returns nil on symlink / ownership / chmod / decode failure, leaving the
/// file in place.
private static func readLegacyFile() -> CredentialRecord? {
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
return record
guard let data = try? SafeFile.readAfterSecuringPermissions(
from: url.path,
maxBytes: maxCredentialBytes
) else { return nil }
guard let decoded = try? JSONDecoder().decode(CredentialRecord.self, from: data) else {
// Invalid data stays in place at 0600; do not delete.
return nil
}
return CredentialRecord(
accessToken: decoded.accessToken,
refreshToken: nil,
expiresAt: decoded.expiresAt,
rateLimitTier: decoded.rateLimitTier
)
}
private static func writeOurCache(record: CredentialRecord) throws {
try writeOurFileCache(record: record)
/// Secure-read legacy JSON, upsert Keychain, verify read-back, then unlink.
private static func migrateLegacyFileIfPresent() throws -> CredentialRecord? {
guard let migrated = readLegacyFile() else { return nil }
// Keychain write/read-back failure leaves the repaired 0600 legacy file
// in place; the next read retries the migration.
try? writeOurCache(record: migrated)
return migrated
}
private static func writeOurFileCache(record: CredentialRecord) throws {
/// Unlink the redundant legacy JSON. Called on every successful cache read,
/// so a failure here is retried on the next read without a sticky flag.
private static func tryUnlinkLegacyAfterVerifiedKeychain() {
let url = cacheFileURL()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let data = try JSONEncoder().encode(record)
try data.write(to: url, options: [.atomic, .completeFileProtection])
guard FileManager.default.fileExists(atPath: url.path) else { return }
do {
if let unlinkLegacyOverride {
try unlinkLegacyOverride(url)
} else {
try FileManager.default.removeItem(at: url)
}
} catch {
// Could not remove it at least make sure it is not world-readable.
if let tightenLegacyOverride {
try? tightenLegacyOverride(url)
} else {
try? SafeFile.tightenToOwnerReadWrite(at: url.path)
}
}
}
private static func deleteOurCache() {
try? FileManager.default.removeItem(at: cacheFileURL())
@discardableResult
private static func deleteOurCache() -> CacheDeleteResult {
var keychainOK = true
do {
try keychainCache.delete(service: ourKeychainService, account: ourKeychainAccount)
} catch {
keychainOK = false
}
var legacyOK = true
let url = cacheFileURL()
if FileManager.default.fileExists(atPath: url.path) {
do {
if let unlinkLegacyOverride {
try unlinkLegacyOverride(url)
} else {
try FileManager.default.removeItem(at: url)
}
} catch {
legacyOK = false
}
}
return CacheDeleteResult(
keychainDeletedOrAbsent: keychainOK,
legacyDeletedOrAbsent: legacyOK
)
}
/// Clears only the in-memory TTL cache (simulates process restart in tests).
static func clearMemoryCacheForTesting() {
lock.withLock { memoryCache = nil }
}
private static func cacheInMemory(_ record: CredentialRecord) {

View file

@ -99,9 +99,14 @@ enum ClaudeSubscriptionService {
}
/// Reset everything used on user-initiated disconnect.
static func disconnect() {
ClaudeCredentialStore.resetBootstrap()
clearUsageBlock()
/// Returns the delete outcome so callers only tear down UI state once the
/// credential material is actually gone. A failed delete leaves the usage
/// block intact too, so a retry starts from the same state.
@discardableResult
static func disconnect() -> ClaudeCredentialStore.CacheDeleteResult {
let result = ClaudeCredentialStore.resetBootstrap()
if result.isSuccess { clearUsageBlock() }
return result
}
// MARK: - Internal

View file

@ -5,7 +5,7 @@ import Security
/// ClaudeCredentialStore but reads from ~/.codex/auth.json Codex CLI
/// already stores its tokens as plaintext JSON in the home directory, so
/// no keychain prompt is involved on bootstrap. After the user clicks
/// Connect we cache a copy under ~/Library/Application Support/CodeBurn so
/// Connect we cache a CodeBurn-owned copy in the macOS Keychain so
/// we keep using rotated tokens after refresh.
enum CodexCredentialStore {
private static let bootstrapCompletedKey = "codeburn.codex.bootstrapCompleted"
@ -23,9 +23,38 @@ enum CodexCredentialStore {
private static let cacheFilename = "codex-credentials.v1.json"
static let ourKeychainService = CodeBurnKeychainIdentity.codexService
static let ourKeychainAccount = CodeBurnKeychainIdentity.account
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
// MARK: - Injectable seams (tests + staged Keychain migration)
nonisolated(unsafe) static var applicationSupportDirectoryOverride: URL?
nonisolated(unsafe) static var homeDirectoryOverride: URL?
nonisolated(unsafe) static var userDefaultsOverride: UserDefaults?
nonisolated(unsafe) static var keychainCache: any KeychainCredentialCaching = LiveKeychainCredentialCache()
static func resetTestSeams() {
applicationSupportDirectoryOverride = nil
homeDirectoryOverride = nil
userDefaultsOverride = nil
keychainCache = LiveKeychainCredentialCache()
lastCacheDeleteResult = nil
unlinkLegacyOverride = nil
tightenLegacyOverride = nil
lock.withLock { memoryCache = nil }
}
private static var defaults: UserDefaults {
userDefaultsOverride ?? .standard
}
private static var homeDirectory: URL {
homeDirectoryOverride ?? FileManager.default.homeDirectoryForCurrentUser
}
struct CachedRecord {
let record: CredentialRecord
let cachedAt: Date
@ -97,16 +126,31 @@ enum CodexCredentialStore {
// MARK: - Bootstrap state
static var isBootstrapCompleted: Bool {
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
get { defaults.bool(forKey: bootstrapCompletedKey) }
set { defaults.set(newValue, forKey: bootstrapCompletedKey) }
}
static func resetBootstrap() {
static func resetBootstrap() -> CacheDeleteResult {
lock.withLock { memoryCache = nil }
deleteOurCache()
isBootstrapCompleted = false
let result = deleteOurCache()
lastCacheDeleteResult = result
if result.isSuccess {
isBootstrapCompleted = false
}
return result
}
struct CacheDeleteResult: Equatable {
var keychainDeletedOrAbsent: Bool
var legacyDeletedOrAbsent: Bool
var isSuccess: Bool { keychainDeletedOrAbsent && legacyDeletedOrAbsent }
}
nonisolated(unsafe) static var lastCacheDeleteResult: CacheDeleteResult?
nonisolated(unsafe) static var unlinkLegacyOverride: ((URL) throws -> Void)?
nonisolated(unsafe) static var tightenLegacyOverride: ((URL) throws -> Void)?
// MARK: - Public API
@discardableResult
@ -132,7 +176,18 @@ enum CodexCredentialStore {
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
return cached.record
}
if let stored = try readOurCache() {
let fetched: CredentialRecord?
do {
fetched = try readOurCache()
} catch let err as KeychainCredentialCacheError {
// Locked/denied keychain: serve the last known token rather than
// reporting the grant as missing.
if case .unavailable = err {
return lock.withLock { memoryCache }?.record
}
throw err
}
if let stored = fetched {
cacheInMemory(stored)
return stored
}
@ -170,7 +225,7 @@ enum CodexCredentialStore {
// MARK: - Bootstrap source: ~/.codex/auth.json
private static func readCodexAuth() throws -> CredentialRecord {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath)
let url = homeDirectory.appendingPathComponent(codexAuthPath)
guard FileManager.default.fileExists(atPath: url.path) else {
throw StoreError.bootstrapNoSource
}
@ -231,7 +286,7 @@ enum CodexCredentialStore {
/// key (OPENAI_API_KEY, auth_mode, ...) and only rewrites the tokens dict and
/// last_refresh. Keeps the CLI and the menubar on the same rotated grant.
private static func writeBackToCodexAuth(record: CredentialRecord) {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath)
let url = homeDirectory.appendingPathComponent(codexAuthPath)
var json: [String: Any] = [:]
if let data = try? SafeFile.read(from: url.path, maxBytes: maxCredentialBytes),
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
@ -250,40 +305,152 @@ enum CodexCredentialStore {
guard let out = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) else {
return
}
try? out.write(to: url, options: .atomic)
try? SafeFile.write(out, to: url.path, mode: 0o600)
}
// MARK: - Local cache file
// MARK: - Local cache (injectable Application Support + Keychain seam)
private static func cacheFileURL() -> URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
static func cacheFileURL() -> URL {
let support = applicationSupportDirectoryOverride
?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? homeDirectory.appendingPathComponent("Library/Application Support")
return support
.appendingPathComponent("CodeBurn", isDirectory: true)
.appendingPathComponent(cacheFilename)
}
/// Production-used write path. Persists rotation fields to the CodeBurn
/// Keychain item. After verified read-back, unlinks any legacy JSON.
static func writeOurCache(record: CredentialRecord) throws {
let data = try JSONEncoder().encode(record)
try keychainCache.upsert(
service: ourKeychainService,
account: ourKeychainAccount,
data: data
)
guard let readBack = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount),
let roundTrip = try? JSONDecoder().decode(CredentialRecord.self, from: readBack),
roundTrip.accessToken == record.accessToken,
roundTrip.refreshToken == record.refreshToken,
roundTrip.idToken == record.idToken,
roundTrip.accountId == record.accountId
else {
throw StoreError.fileWriteFailed("keychain read-back mismatch")
}
tryUnlinkLegacyAfterVerifiedKeychain()
}
/// Serializes migrate + unlink across processes so two menubar instances (or the
/// CLI) cannot race on the same legacy file. Only `SafeFile.Error` from acquiring
/// the lock is tolerated `readOurCacheLocked` never lets one escape, because
/// `readLegacyFile` swallows its own read failures.
private static func readOurCache() throws -> CredentialRecord? {
do {
return try SafeFile.withExclusiveLock(at: cacheFileURL().path + ".lock") {
try readOurCacheLocked()
}
} catch is SafeFile.Error {
return try readOurCacheLocked()
}
}
private static func readOurCacheLocked() throws -> CredentialRecord? {
if let data = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount),
let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) {
// Only reached when auth.json is unreadable, but the Keychain item can
// still be older than the legacy file and serving a spent rotating
// refresh token here ends in a terminal invalid_grant. Prefer the
// later `lastRefresh` before unlinking anything.
if let fresher = legacyRecordIfNewer(than: record) {
try? writeOurCache(record: fresher)
return fresher
}
tryUnlinkLegacyAfterVerifiedKeychain()
return record
}
return try migrateLegacyFileIfPresent()
}
/// Returns the legacy file's record when it refreshed strictly later than
/// `record`; nil when absent, unreadable, or not newer.
private static func legacyRecordIfNewer(than record: CredentialRecord) -> CredentialRecord? {
guard let legacy = readLegacyFile() else { return nil }
guard let legacyRefresh = legacy.lastRefresh else { return nil }
guard let currentRefresh = record.lastRefresh else { return legacy }
return legacyRefresh > currentRefresh ? legacy : nil
}
/// Secure-read + decode the legacy JSON. Returns nil on symlink / ownership /
/// chmod / decode failure, leaving the file in place.
private static func readLegacyFile() -> CredentialRecord? {
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
return record
guard let data = try? SafeFile.readAfterSecuringPermissions(
from: url.path,
maxBytes: maxCredentialBytes
) else { return nil }
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
}
private static func writeOurCache(record: CredentialRecord) throws {
try writeOurFileCache(record: record)
private static func migrateLegacyFileIfPresent() throws -> CredentialRecord? {
guard let decoded = readLegacyFile() else { return nil }
// Keychain write/read-back failure leaves the repaired 0600 legacy file
// in place; the next read retries the migration.
try? writeOurCache(record: decoded)
return decoded
}
private static func writeOurFileCache(record: CredentialRecord) throws {
/// Unlink the redundant legacy JSON. Called on every successful cache read,
/// so a failure here is retried on the next read without a sticky flag.
private static func tryUnlinkLegacyAfterVerifiedKeychain() {
let url = cacheFileURL()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let data = try JSONEncoder().encode(record)
try data.write(to: url, options: [.atomic, .completeFileProtection])
guard FileManager.default.fileExists(atPath: url.path) else { return }
do {
if let unlinkLegacyOverride {
try unlinkLegacyOverride(url)
} else {
try FileManager.default.removeItem(at: url)
}
} catch {
// Could not remove it at least make sure it is not world-readable.
if let tightenLegacyOverride {
try? tightenLegacyOverride(url)
} else {
try? SafeFile.tightenToOwnerReadWrite(at: url.path)
}
}
}
private static func deleteOurCache() {
try? FileManager.default.removeItem(at: cacheFileURL())
@discardableResult
private static func deleteOurCache() -> CacheDeleteResult {
var keychainOK = true
do {
try keychainCache.delete(service: ourKeychainService, account: ourKeychainAccount)
} catch {
keychainOK = false
}
var legacyOK = true
let url = cacheFileURL()
if FileManager.default.fileExists(atPath: url.path) {
do {
if let unlinkLegacyOverride {
try unlinkLegacyOverride(url)
} else {
try FileManager.default.removeItem(at: url)
}
} catch {
legacyOK = false
}
}
return CacheDeleteResult(
keychainDeletedOrAbsent: keychainOK,
legacyDeletedOrAbsent: legacyOK
)
}
static func clearMemoryCacheForTesting() {
lock.withLock { memoryCache = nil }
}
private static func cacheInMemory(_ record: CredentialRecord) {

View file

@ -78,9 +78,14 @@ enum CodexSubscriptionService {
}
}
static func disconnect() {
CodexCredentialStore.resetBootstrap()
clearUsageBlock()
/// Returns the delete outcome so callers only tear down UI state once the
/// credential material is actually gone. A failed delete leaves the usage
/// block intact too, so a retry starts from the same state.
@discardableResult
static func disconnect() -> CodexCredentialStore.CacheDeleteResult {
let result = CodexCredentialStore.resetBootstrap()
if result.isSuccess { clearUsageBlock() }
return result
}
private static func fetchWithToken(_ token: String, allowOne401Recovery: Bool) async throws -> CodexUsage {

View file

@ -0,0 +1,248 @@
import Foundation
import LocalAuthentication
import Security
/// Serializes credential-store test harnesses that mutate process-wide seams.
enum CredentialStoreTestIsolation {
static let lock = NSLock()
}
/// Narrow CodeBurn-owned Keychain cache over exact service/account pairs.
///
/// Production uses `LiveKeychainCredentialCache`. Tests inject
/// `InMemoryKeychainCredentialCache` so the suite never touches the login
/// Keychain. Errors carry only operation, service, and OSStatus never blob data.
protocol KeychainCredentialCaching: Sendable {
func read(service: String, account: String) throws -> Data?
func upsert(service: String, account: String, data: Data) throws
func delete(service: String, account: String) throws
}
enum KeychainCredentialCacheError: Error, LocalizedError, Equatable {
case readFailed(service: String, status: OSStatus)
case writeFailed(service: String, status: OSStatus)
case deleteFailed(service: String, status: OSStatus)
/// Keychain is locked or consent was refused. Transient callers keep the
/// last known token and must not treat it as "the item is gone".
case unavailable(service: String, status: OSStatus)
var errorDescription: String? {
switch self {
case let .readFailed(service, status):
return "Keychain read failed for \(service) (status \(status))."
case let .writeFailed(service, status):
return "Keychain write failed for \(service) (status \(status))."
case let .deleteFailed(service, status):
return "Keychain delete failed for \(service) (status \(status))."
case .unavailable:
return "Keychain unavailable — unlock your login keychain to refresh quota."
}
}
/// Statuses that mean "we were not allowed to look right now", as opposed to
/// "the item does not exist". `errSecInteractionNotAllowed` (-25308) is what
/// a locked keychain returns once UI is suppressed.
static func isUnavailable(_ status: OSStatus) -> Bool {
status == errSecInteractionNotAllowed
|| status == errSecAuthFailed
|| status == errSecUserCanceled
|| status == errSecInteractionRequired
}
}
/// Published CodeBurn Keychain identities. Keep these exact Electron contracts
/// on the Codex pair (`app/electron/quota/codex.ts`), and installs going back to
/// May 2026 already hold items under these names.
///
/// Deliberately NOT derived from `CFBundleIdentifier`: the Electron app hardcodes
/// the same strings, so a per-bundle suffix would break that contract. The
/// tradeoff is that a dev/beta build sharing this source shares the item patch
/// these constants when running a second build alongside the release.
enum CodeBurnKeychainIdentity {
static let claudeService = "org.agentseal.codeburn.menubar.claude.oauth.v1"
static let codexService = "org.agentseal.codeburn.menubar.codex.oauth.v1"
static let account = "default"
}
struct LiveKeychainCredentialCache: KeychainCredentialCaching {
/// True when the default (login) keychain exists and is currently locked.
/// Returns false when the state cannot be determined, so an unexpected
/// failure degrades to "just try the read" rather than a hard outage.
///
/// `SecKeychain*` is soft-deprecated with no replacement that reports
/// file-keychain lock state `kSecUseDataProtectionKeychain` would move our
/// item to a different store and orphan every existing install. The
/// This is the one intentional deprecation warning in the file; annotating it
/// away only moves the warning to the call site, so it is left visible.
private func isDefaultKeychainLocked() -> Bool {
var status: SecKeychainStatus = 0
guard SecKeychainGetStatus(nil, &status) == errSecSuccess else { return false }
return (status & SecKeychainStatus(kSecUnlockStateStatus)) == 0
}
func read(service: String, account: String) throws -> Data? {
// Reads happen on the background refresh timer, so they must never be
// able to raise UI. Measured on macOS 15 against a locked test keychain:
// NEITHER `kSecUseAuthenticationUI: Fail` NOR
// `LAContext.interactionNotAllowed` suppresses the unlock panel for a
// file-based keychain both govern the data-protection keychain, while
// unlocking is a keychain-level operation securityd drives itself. The
// only thing that reliably avoids the panel is not issuing the read at
// all, so check lock state first. Same class of bug as the
// partition-list re-prompt in #490.
if isDefaultKeychainLocked() {
throw KeychainCredentialCacheError.unavailable(
service: service, status: errSecInteractionNotAllowed)
}
// Still pass a non-interactive context: it is the supported way to keep
// a data-protection-backed item from raising biometric/passcode UI.
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
if KeychainCredentialCacheError.isUnavailable(status) {
throw KeychainCredentialCacheError.unavailable(service: service, status: status)
}
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainCredentialCacheError.readFailed(service: service, status: status)
}
return data
}
func upsert(service: String, account: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecSuccess { return }
if updateStatus == errSecItemNotFound {
var add = query
add[kSecValueData as String] = data
let addStatus = SecItemAdd(add as CFDictionary, nil)
if addStatus == errSecSuccess { return }
if addStatus == errSecDuplicateItem {
let retry = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard retry == errSecSuccess else {
throw KeychainCredentialCacheError.writeFailed(service: service, status: retry)
}
return
}
throw KeychainCredentialCacheError.writeFailed(service: service, status: addStatus)
}
throw KeychainCredentialCacheError.writeFailed(service: service, status: updateStatus)
}
func delete(service: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound { return }
throw KeychainCredentialCacheError.deleteFailed(service: service, status: status)
}
}
/// Process-local fake for tests. Never writes to the system Keychain.
final class InMemoryKeychainCredentialCache: KeychainCredentialCaching, @unchecked Sendable {
private let lock = NSLock()
private var items: [String: Data] = [:]
private(set) var upsertCount = 0
private(set) var readCount = 0
private(set) var deleteCount = 0
private func key(_ service: String, _ account: String) -> String {
"\(service)\u{1f}\(account)"
}
func read(service: String, account: String) throws -> Data? {
lock.lock(); defer { lock.unlock() }
readCount += 1
return items[key(service, account)]
}
func upsert(service: String, account: String, data: Data) throws {
lock.lock(); defer { lock.unlock() }
upsertCount += 1
items[key(service, account)] = data
}
func delete(service: String, account: String) throws {
lock.lock(); defer { lock.unlock() }
deleteCount += 1
items.removeValue(forKey: key(service, account))
}
func storedJSONObject(service: String, account: String) -> [String: Any]? {
lock.lock(); defer { lock.unlock() }
guard let data = items[key(service, account)] else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}
func storedKeys(service: String, account: String) -> [String]? {
storedJSONObject(service: service, account: account).map { Array($0.keys).sorted() }
}
/// Snapshot for simulated process restart: keep Keychain bytes, drop nothing else.
func cloneStorage() -> InMemoryKeychainCredentialCache {
lock.lock(); defer { lock.unlock() }
let copy = InMemoryKeychainCredentialCache()
copy.items = items
return copy
}
}
/// Test double that wraps another backend and can force upsert/read/delete failures.
final class ControllableKeychainCredentialCache: KeychainCredentialCaching, @unchecked Sendable {
private let inner: any KeychainCredentialCaching
var failUpsert = false
var failRead = false
var failDelete = false
var upsertStatus: OSStatus = -1
var readStatus: OSStatus = -1
var deleteStatus: OSStatus = -1
init(inner: any KeychainCredentialCaching) {
self.inner = inner
}
func read(service: String, account: String) throws -> Data? {
if failRead {
// Mirror the live adapter's mapping so tests exercise the same branch.
if KeychainCredentialCacheError.isUnavailable(readStatus) {
throw KeychainCredentialCacheError.unavailable(service: service, status: readStatus)
}
throw KeychainCredentialCacheError.readFailed(service: service, status: readStatus)
}
return try inner.read(service: service, account: account)
}
func upsert(service: String, account: String, data: Data) throws {
if failUpsert {
throw KeychainCredentialCacheError.writeFailed(service: service, status: upsertStatus)
}
try inner.upsert(service: service, account: account, data: data)
}
func delete(service: String, account: String) throws {
if failDelete {
throw KeychainCredentialCacheError.deleteFailed(service: service, status: deleteStatus)
}
try inner.delete(service: service, account: account)
}
}

View file

@ -21,6 +21,42 @@ enum SafeFile {
/// from exhausting memory in the Swift process.
static let defaultReadLimit = 8 * 1024 * 1024
/// Open the existing regular file with O_NOFOLLOW, fchmod 0600, and
/// fstat-verify the mode. Used when leftover credential JSON cannot be
/// unlinked after a verified Keychain write.
static func tightenToOwnerReadWrite(at path: String) throws {
var linkInfo = stat()
guard lstat(path, &linkInfo) == 0 else {
throw Error.readFailed(path, errno)
}
if (linkInfo.st_mode & S_IFMT) == S_IFLNK {
throw Error.symlinkDetected(path)
}
let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW)
guard fd >= 0 else {
throw Error.readFailed(path, errno)
}
defer { Darwin.close(fd) }
var opened = stat()
guard fstat(fd, &opened) == 0 else {
throw Error.readFailed(path, errno)
}
guard (opened.st_mode & S_IFMT) == S_IFREG else {
throw SecureReadError.notRegularFile(path)
}
if fchmod(fd, 0o600) != 0 {
throw SecureReadError.chmodFailed(path, errno)
}
var verified = stat()
guard fstat(fd, &verified) == 0 else {
throw Error.readFailed(path, errno)
}
let mode = verified.st_mode & 0o777
guard mode == 0o600 else {
throw SecureReadError.modeVerifyFailed(path, mode)
}
}
/// Refuses to follow symlinks and writes atomically via a tmp file + rename. `mode` is the
/// final file permission (0o600 by default so cache files stay user-private).
static func write(_ data: Data, to path: String, mode: mode_t = 0o600) throws {
@ -101,6 +137,96 @@ enum SafeFile {
return data
}
enum SecureReadError: Swift.Error, Equatable {
case notRegularFile(String)
case wrongOwner(String)
case chmodFailed(String, Int32)
case modeVerifyFailed(String, mode_t)
}
/// Legacy credential migration path: open with `O_NOFOLLOW`, refuse non-regular /
/// non-owned files, `fchmod(0600)` and verify mode, then read bounded bytes from
/// the same descriptor. Permissions are repaired before any secret byte is read.
///
/// The chmod deliberately precedes any content check: validating JSON first would
/// mean reading the secret while it is still world-readable, which is the exact
/// window this function exists to close. The cost is that a non-credential file
/// sitting at the caller's exact cache path also gets tightened to 0600 bounded
/// to our own Application Support directory, and already symlink- and owner-checked.
static func readAfterSecuringPermissions(
from path: String,
maxBytes: Int = defaultReadLimit,
expectedOwner: uid_t = geteuid()
) throws -> Data {
var linkInfo = stat()
guard lstat(path, &linkInfo) == 0 else {
throw Error.readFailed(path, errno)
}
if (linkInfo.st_mode & S_IFMT) == S_IFLNK {
throw Error.symlinkDetected(path)
}
guard (linkInfo.st_mode & S_IFMT) == S_IFREG else {
throw SecureReadError.notRegularFile(path)
}
guard linkInfo.st_uid == expectedOwner else {
throw SecureReadError.wrongOwner(path)
}
let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW)
guard fd >= 0 else {
throw Error.readFailed(path, errno)
}
defer { Darwin.close(fd) }
var opened = stat()
guard fstat(fd, &opened) == 0 else {
throw Error.readFailed(path, errno)
}
guard (opened.st_mode & S_IFMT) == S_IFREG else {
throw SecureReadError.notRegularFile(path)
}
guard opened.st_uid == expectedOwner else {
throw SecureReadError.wrongOwner(path)
}
if fchmod(fd, 0o600) != 0 {
throw SecureReadError.chmodFailed(path, errno)
}
var verified = stat()
guard fstat(fd, &verified) == 0 else {
throw Error.readFailed(path, errno)
}
let mode = verified.st_mode & 0o777
guard mode == 0o600 else {
throw SecureReadError.modeVerifyFailed(path, mode)
}
let size = Int(verified.st_size)
if size > maxBytes {
throw Error.sizeLimitExceeded(path, size)
}
var data = Data()
data.reserveCapacity(max(size, 0))
var chunk = [UInt8](repeating: 0, count: 4096)
let limit = maxBytes + 1
while data.count < limit {
let n = chunk.withUnsafeMutableBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return 0 }
return Darwin.read(fd, base, min(buffer.count, limit - data.count))
}
guard n >= 0 else {
throw Error.readFailed(path, errno)
}
if n == 0 { break }
data.append(contentsOf: chunk.prefix(n))
}
if data.count > maxBytes {
throw Error.sizeLimitExceeded(path, data.count)
}
return data
}
/// Runs `body` while holding an exclusive POSIX advisory lock on `path`. The lock file is
/// created if missing (with 0o600 permissions) and released on scope exit, so other
/// codeburn processes (the CLI running in a terminal, say) block on the same file instead

View file

@ -508,7 +508,7 @@ private struct CodexSettingsTab: View {
CodexConnectionRow()
}
Section {
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.")
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a CodeBurn-owned copy in your login Keychain instead of a world-readable file, so subsequent quota fetches don't re-read the original. The item is reachable by programs running as you, the same as any login-Keychain entry. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {

View file

@ -0,0 +1,149 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
/// Red receipt for 0B: current plaintext writers must fail these assertions.
/// Disposable sentinels only never log or expect raw secret values in receipts.
@Suite("Credential Keychain cache red", .serialized)
struct CredentialKeychainCacheRedTests {
private let accessSentinel = "cb-red-access-sentinel"
private let refreshSentinel = "cb-red-refresh-sentinel"
private let idSentinel = "cb-red-id-sentinel"
private let accountSentinel = "cb-red-account-sentinel"
private func withIsolatedSeams(
_ body: (URL, InMemoryKeychainCredentialCache) throws -> Void
) throws {
CredentialStoreTestIsolation.lock.lock()
defer { CredentialStoreTestIsolation.lock.unlock() }
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("codeburn-0b-red-\(UUID().uuidString)", isDirectory: true)
let support = root.appendingPathComponent("Application Support", isDirectory: true)
try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true)
let suiteName = "codeburn.0b.red.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
let fakeKeychain = InMemoryKeychainCredentialCache()
ClaudeCredentialStore.resetTestSeams()
CodexCredentialStore.resetTestSeams()
ClaudeCredentialStore.applicationSupportDirectoryOverride = support
CodexCredentialStore.applicationSupportDirectoryOverride = support
ClaudeCredentialStore.userDefaultsOverride = defaults
CodexCredentialStore.userDefaultsOverride = defaults
ClaudeCredentialStore.keychainCache = fakeKeychain
CodexCredentialStore.keychainCache = fakeKeychain
defer {
ClaudeCredentialStore.resetTestSeams()
CodexCredentialStore.resetTestSeams()
defaults.removePersistentDomain(forName: suiteName)
try? FileManager.default.removeItem(at: root)
}
try body(support, fakeKeychain)
}
private func posixMode(at url: URL) -> mode_t? {
var info = stat()
guard lstat(url.path, &info) == 0 else { return nil }
return info.st_mode & 0o777
}
private func jsonKeys(at url: URL) throws -> [String] {
let data = try Data(contentsOf: url)
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
return object.keys.sorted()
}
@Test("Claude writeOurCache leaves no JSON and stores Keychain payload without refreshToken")
func claudeWriteUsesKeychainWithoutRefreshToken() throws {
try withIsolatedSeams { support, fakeKeychain in
let record = ClaudeCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
)
try ClaudeCredentialStore.writeOurCache(record: record)
let legacyURL = ClaudeCredentialStore.cacheFileURL()
let legacyExists = FileManager.default.fileExists(atPath: legacyURL.path)
let mode = legacyExists ? posixMode(at: legacyURL) : nil
let fileKeys = legacyExists ? try jsonKeys(at: legacyURL) : []
let keychainKeys = fakeKeychain.storedKeys(
service: ClaudeCredentialStore.ourKeychainService,
account: ClaudeCredentialStore.ourKeychainAccount
)
let keychainObject = fakeKeychain.storedJSONObject(
service: ClaudeCredentialStore.ourKeychainService,
account: ClaudeCredentialStore.ourKeychainAccount
)
let hasRefreshKey = keychainObject?.keys.contains("refreshToken") == true
let refreshValueMatches = (keychainObject?["refreshToken"] as? String) == refreshSentinel
// Intended green behavior (must fail against current plaintext writer):
#expect(!legacyExists, "legacy Claude JSON must not be created under Application Support")
#expect(fakeKeychain.upsertCount >= 1, "fake Keychain must receive a Claude upsert")
#expect(keychainKeys != nil, "Claude Keychain payload must exist")
#expect(!(keychainKeys?.contains("refreshToken") ?? false), "Claude Keychain keys must omit refreshToken")
#expect(!hasRefreshKey && !refreshValueMatches, "Claude Keychain must not persist refreshToken")
// Red diagnostic (keys + mode only; never fixture values):
if legacyExists {
Issue.record(
Comment(rawValue: "RED Claude legacy present mode=\(String(mode.map { String($0, radix: 8) } ?? "nil")) keys=\(fileKeys.joined(separator: ","))")
)
}
if fakeKeychain.upsertCount == 0 {
Issue.record(Comment(rawValue: "RED Claude Keychain upsertCount=0"))
}
_ = support
}
}
@Test("Codex writeOurCache leaves no JSON and stores Keychain rotation fields")
func codexWriteUsesKeychainWithRotationFields() throws {
try withIsolatedSeams { support, fakeKeychain in
let record = CodexCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
idToken: idSentinel,
accountId: accountSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_100),
lastRefresh: Date(timeIntervalSince1970: 1_700_000_000)
)
try CodexCredentialStore.writeOurCache(record: record)
let legacyURL = CodexCredentialStore.cacheFileURL()
let legacyExists = FileManager.default.fileExists(atPath: legacyURL.path)
let mode = legacyExists ? posixMode(at: legacyURL) : nil
let fileKeys = legacyExists ? try jsonKeys(at: legacyURL) : []
let keychainKeys = fakeKeychain.storedKeys(
service: CodexCredentialStore.ourKeychainService,
account: CodexCredentialStore.ourKeychainAccount
)
let required = ["accessToken", "refreshToken", "idToken", "accountId", "lastRefresh"]
let missingRequired = required.filter { !(keychainKeys?.contains($0) ?? false) }
#expect(!legacyExists, "legacy Codex JSON must not be created under Application Support")
#expect(fakeKeychain.upsertCount >= 1, "fake Keychain must receive a Codex upsert")
#expect(keychainKeys != nil, "Codex Keychain payload must exist")
#expect(missingRequired.isEmpty, "Codex Keychain must retain rotation fields")
if legacyExists {
Issue.record(
Comment(rawValue: "RED Codex legacy present mode=\(String(mode.map { String($0, radix: 8) } ?? "nil")) keys=\(fileKeys.joined(separator: ","))")
)
}
if fakeKeychain.upsertCount == 0 {
Issue.record(Comment(rawValue: "RED Codex Keychain upsertCount=0"))
}
_ = support
}
}
}

View file

@ -0,0 +1,557 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
/// Implementation-continuity tests for option-3 evidence bar.
/// Uses only InMemory/Controllable Keychain backends and temp Application Support
/// never the operator login Keychain or live credential files.
@Suite("Credential Keychain implementation continuity", .serialized)
struct CredentialKeychainContinuityTests {
private let accessSentinel = "cb-cont-access-sentinel"
private let refreshSentinel = "cb-cont-refresh-sentinel"
private let idSentinel = "cb-cont-id-sentinel"
private let accountSentinel = "cb-cont-account-sentinel"
private struct Harness {
let root: URL
let support: URL
let defaults: UserDefaults
let suiteName: String
let fakeKeychain: InMemoryKeychainCredentialCache
}
private func withHarness(
_ body: (Harness) throws -> Void
) throws {
CredentialStoreTestIsolation.lock.lock()
defer { CredentialStoreTestIsolation.lock.unlock() }
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("codeburn-0b-cont-\(UUID().uuidString)", isDirectory: true)
let support = root.appendingPathComponent("Application Support", isDirectory: true)
try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true)
let suiteName = "codeburn.0b.cont.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
let fake = InMemoryKeychainCredentialCache()
ClaudeCredentialStore.resetTestSeams()
CodexCredentialStore.resetTestSeams()
ClaudeCredentialStore.applicationSupportDirectoryOverride = support
CodexCredentialStore.applicationSupportDirectoryOverride = support
ClaudeCredentialStore.homeDirectoryOverride = root
CodexCredentialStore.homeDirectoryOverride = root
ClaudeCredentialStore.userDefaultsOverride = defaults
CodexCredentialStore.userDefaultsOverride = defaults
ClaudeCredentialStore.keychainCache = fake
CodexCredentialStore.keychainCache = fake
defer {
ClaudeCredentialStore.resetTestSeams()
CodexCredentialStore.resetTestSeams()
defaults.removePersistentDomain(forName: suiteName)
try? FileManager.default.removeItem(at: root)
}
try body(Harness(root: root, support: support, defaults: defaults, suiteName: suiteName, fakeKeychain: fake))
}
private func posixMode(at url: URL) -> mode_t? {
var info = stat()
guard lstat(url.path, &info) == 0 else { return nil }
return info.st_mode & 0o777
}
private func writeLegacyClaude0644(record: ClaudeCredentialStore.CredentialRecord) throws {
let url = ClaudeCredentialStore.cacheFileURL()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let data = try JSONEncoder().encode(record)
try data.write(to: url)
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path)
}
private func writeLegacyCodex0644(record: CodexCredentialStore.CredentialRecord) throws {
let url = CodexCredentialStore.cacheFileURL()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let data = try JSONEncoder().encode(record)
try data.write(to: url)
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path)
}
// MARK: - Continuity lifecycle
@Test("Claude write → simulated restart → read → update → delete")
func claudeWriteRestartReadUpdateDelete() throws {
try withHarness { harness in
let initial = ClaudeCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
)
try ClaudeCredentialStore.writeOurCache(record: initial)
ClaudeCredentialStore.isBootstrapCompleted = true
#expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
let keys = harness.fakeKeychain.storedKeys(
service: ClaudeCredentialStore.ourKeychainService,
account: ClaudeCredentialStore.ourKeychainAccount
)
#expect(keys?.contains("refreshToken") != true)
#expect(keys?.contains("accessToken") == true)
// Simulated process restart: drop memory, keep Keychain bytes.
ClaudeCredentialStore.clearMemoryCacheForTesting()
let afterRestart = try #require(try ClaudeCredentialStore.currentRecord())
#expect(afterRestart.accessToken == accessSentinel)
#expect(afterRestart.refreshToken == nil)
let updated = ClaudeCredentialStore.CredentialRecord(
accessToken: accessSentinel + "-rotated",
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_100),
rateLimitTier: "default"
)
try ClaudeCredentialStore.writeOurCache(record: updated)
ClaudeCredentialStore.clearMemoryCacheForTesting()
let afterUpdate = try #require(try ClaudeCredentialStore.currentRecord())
#expect(afterUpdate.accessToken == accessSentinel + "-rotated")
let deleteResult = ClaudeCredentialStore.resetBootstrap()
#expect(deleteResult.isSuccess)
let afterDelete = try harness.fakeKeychain.read(
service: ClaudeCredentialStore.ourKeychainService,
account: ClaudeCredentialStore.ourKeychainAccount
)
#expect(afterDelete == nil)
let afterDisconnect = try ClaudeCredentialStore.currentRecord()
#expect(afterDisconnect == nil)
}
}
@Test("Codex write → simulated restart → read → update → delete")
func codexWriteRestartReadUpdateDelete() throws {
try withHarness { harness in
let initial = CodexCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
idToken: idSentinel,
accountId: accountSentinel,
expiresAt: nil,
lastRefresh: Date(timeIntervalSince1970: 1_700_000_000)
)
try CodexCredentialStore.writeOurCache(record: initial)
CodexCredentialStore.isBootstrapCompleted = true
#expect(!FileManager.default.fileExists(atPath: CodexCredentialStore.cacheFileURL().path))
let keys = harness.fakeKeychain.storedKeys(
service: CodexCredentialStore.ourKeychainService,
account: CodexCredentialStore.ourKeychainAccount
)
for required in ["accessToken", "refreshToken", "idToken", "accountId", "lastRefresh"] {
#expect(keys?.contains(required) == true)
}
CodexCredentialStore.clearMemoryCacheForTesting()
// Without ~/.codex/auth.json, currentRecord falls through to Keychain cache.
let afterRestart = try #require(try CodexCredentialStore.currentRecord())
#expect(afterRestart.accessToken == accessSentinel)
#expect(afterRestart.refreshToken == refreshSentinel)
let updated = CodexCredentialStore.CredentialRecord(
accessToken: accessSentinel + "-rotated",
refreshToken: refreshSentinel + "-rotated",
idToken: idSentinel,
accountId: accountSentinel,
expiresAt: nil,
lastRefresh: Date(timeIntervalSince1970: 1_700_000_800)
)
try CodexCredentialStore.writeOurCache(record: updated)
CodexCredentialStore.clearMemoryCacheForTesting()
let afterUpdate = try #require(try CodexCredentialStore.currentRecord())
#expect(afterUpdate.accessToken == accessSentinel + "-rotated")
#expect(afterUpdate.refreshToken == refreshSentinel + "-rotated")
let deleteResult = CodexCredentialStore.resetBootstrap()
#expect(deleteResult.isSuccess)
let afterDisconnect = try CodexCredentialStore.currentRecord()
#expect(afterDisconnect == nil)
}
}
// MARK: - Migration
@Test("successful Claude legacy 0644 migration unlinks JSON and omits refreshToken")
func claudeSuccessfulLegacyMigration() throws {
try withHarness { harness in
let legacy = ClaudeCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
)
try writeLegacyClaude0644(record: legacy)
#expect(posixMode(at: ClaudeCredentialStore.cacheFileURL()) == 0o644)
ClaudeCredentialStore.isBootstrapCompleted = true
let migrated = try #require(try ClaudeCredentialStore.currentRecord())
#expect(migrated.accessToken == accessSentinel)
#expect(migrated.refreshToken == nil)
#expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
#expect(harness.fakeKeychain.upsertCount >= 1)
let keys = harness.fakeKeychain.storedKeys(
service: ClaudeCredentialStore.ourKeychainService,
account: ClaudeCredentialStore.ourKeychainAccount
)
#expect(keys?.contains("refreshToken") != true)
}
}
@Test("failed Claude Keychain upsert leaves secured legacy file")
func claudeFailedMigrationKeepsLegacy() throws {
try withHarness { harness in
let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain)
controllable.failUpsert = true
ClaudeCredentialStore.keychainCache = controllable
let legacy = ClaudeCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
)
try writeLegacyClaude0644(record: legacy)
ClaudeCredentialStore.isBootstrapCompleted = true
let record = try #require(try ClaudeCredentialStore.currentRecord())
#expect(record.accessToken == accessSentinel)
#expect(FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
#expect(posixMode(at: ClaudeCredentialStore.cacheFileURL()) == 0o600)
#expect(harness.fakeKeychain.upsertCount == 0)
}
}
@Test("successful Codex legacy migration unlinks JSON and keeps rotation fields")
func codexSuccessfulLegacyMigration() throws {
try withHarness { harness in
let legacy = CodexCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
idToken: idSentinel,
accountId: accountSentinel,
expiresAt: nil,
lastRefresh: Date(timeIntervalSince1970: 1_700_000_000)
)
try writeLegacyCodex0644(record: legacy)
CodexCredentialStore.isBootstrapCompleted = true
let migrated = try #require(try CodexCredentialStore.currentRecord())
#expect(migrated.refreshToken == refreshSentinel)
#expect(!FileManager.default.fileExists(atPath: CodexCredentialStore.cacheFileURL().path))
let keys = harness.fakeKeychain.storedKeys(
service: CodexCredentialStore.ourKeychainService,
account: CodexCredentialStore.ourKeychainAccount
)
#expect(keys?.contains("refreshToken") == true)
#expect(keys?.contains("lastRefresh") == true)
}
}
@Test("symlink legacy Claude file is refused and left in place")
func claudeSymlinkLegacyRefused() throws {
try withHarness { _ in
let codeburnDir = ClaudeCredentialStore.cacheFileURL().deletingLastPathComponent()
try FileManager.default.createDirectory(at: codeburnDir, withIntermediateDirectories: true)
let target = codeburnDir.appendingPathComponent("not-a-cred.txt")
try Data("x".utf8).write(to: target)
let link = ClaudeCredentialStore.cacheFileURL()
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target)
ClaudeCredentialStore.isBootstrapCompleted = true
let afterSymlink = try ClaudeCredentialStore.currentRecord()
#expect(afterSymlink == nil)
#expect(FileManager.default.fileExists(atPath: link.path))
}
}
// MARK: - Disconnect / reinstall
@Test("disconnect not-found is success; delete failure is observable")
func disconnectIdempotentAndPartialFailure() throws {
try withHarness { harness in
let empty = ClaudeCredentialStore.resetBootstrap()
#expect(empty.isSuccess)
try ClaudeCredentialStore.writeOurCache(record: .init(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: nil,
rateLimitTier: nil
))
ClaudeCredentialStore.isBootstrapCompleted = true
let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain)
controllable.failDelete = true
ClaudeCredentialStore.keychainCache = controllable
let failed = ClaudeCredentialStore.resetBootstrap()
#expect(!failed.isSuccess)
#expect(failed.keychainDeletedOrAbsent == false)
#expect(ClaudeCredentialStore.lastCacheDeleteResult?.isSuccess == false)
#expect(ClaudeCredentialStore.isBootstrapCompleted == true)
}
}
@Test("failed unlink after verified Keychain repairs leftover JSON to 0600")
func failedUnlinkRepairsLegacyMode() throws {
try withHarness { _ in
let record = ClaudeCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
)
try ClaudeCredentialStore.writeOurCache(record: record)
try writeLegacyClaude0644(record: record)
ClaudeCredentialStore.isBootstrapCompleted = true
ClaudeCredentialStore.unlinkLegacyOverride = { _ in
throw POSIXError(.EPERM)
}
ClaudeCredentialStore.clearMemoryCacheForTesting()
_ = try ClaudeCredentialStore.currentRecord()
#expect(FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
#expect(posixMode(at: ClaudeCredentialStore.cacheFileURL()) == 0o600)
// No sticky flag: the next read retries the unlink on its own.
ClaudeCredentialStore.unlinkLegacyOverride = nil
ClaudeCredentialStore.clearMemoryCacheForTesting()
_ = try ClaudeCredentialStore.currentRecord()
#expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
}
}
@Test("failed tighten after failed unlink keeps leftover in place")
func failedTightenLeavesRetrySignal() throws {
try withHarness { _ in
let record = ClaudeCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
)
try ClaudeCredentialStore.writeOurCache(record: record)
try writeLegacyClaude0644(record: record)
ClaudeCredentialStore.isBootstrapCompleted = true
ClaudeCredentialStore.unlinkLegacyOverride = { _ in throw POSIXError(.EPERM) }
ClaudeCredentialStore.tightenLegacyOverride = { _ in throw POSIXError(.EPERM) }
ClaudeCredentialStore.clearMemoryCacheForTesting()
_ = try ClaudeCredentialStore.currentRecord()
#expect(FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
}
}
@Test("legacy-only disconnect failure keeps bootstrap so retry stays")
func legacyOnlyDisconnectKeepsBootstrap() throws {
try withHarness { _ in
try ClaudeCredentialStore.writeOurCache(record: .init(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: nil,
rateLimitTier: nil
))
try writeLegacyClaude0644(record: .init(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: nil,
rateLimitTier: nil
))
ClaudeCredentialStore.isBootstrapCompleted = true
ClaudeCredentialStore.unlinkLegacyOverride = { _ in throw POSIXError(.EPERM) }
let failed = ClaudeCredentialStore.resetBootstrap()
#expect(failed.keychainDeletedOrAbsent == true)
#expect(failed.legacyDeletedOrAbsent == false)
#expect(failed.isSuccess == false)
#expect(ClaudeCredentialStore.isBootstrapCompleted == true)
}
}
@Test("reinstall with empty Keychain and no legacy clears bootstrap on read")
func reinstallMissingCacheClearsBootstrap() throws {
try withHarness { _ in
ClaudeCredentialStore.isBootstrapCompleted = true
let missing = try ClaudeCredentialStore.currentRecord()
#expect(missing == nil)
#expect(ClaudeCredentialStore.isBootstrapCompleted == false)
}
}
// MARK: - Locked / denied Keychain
@Test("unavailable Keychain read is a miss, not a disconnect")
func unavailableKeychainKeepsBootstrap() throws {
try withHarness { harness in
try ClaudeCredentialStore.writeOurCache(record: .init(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
))
ClaudeCredentialStore.isBootstrapCompleted = true
let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain)
controllable.failRead = true
controllable.readStatus = errSecInteractionNotAllowed // -25308
ClaudeCredentialStore.keychainCache = controllable
ClaudeCredentialStore.clearMemoryCacheForTesting()
// Must not throw and must not clear bootstrap: a locked keychain is
// "can't look right now", not "the user disconnected".
#expect(try ClaudeCredentialStore.currentRecord() == nil)
#expect(ClaudeCredentialStore.isBootstrapCompleted == true)
}
}
@Test("a genuine read failure still surfaces as an error")
func nonUnavailableReadStillThrows() throws {
try withHarness { harness in
ClaudeCredentialStore.isBootstrapCompleted = true
let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain)
controllable.failRead = true
controllable.readStatus = errSecDecode
ClaudeCredentialStore.keychainCache = controllable
ClaudeCredentialStore.clearMemoryCacheForTesting()
#expect(throws: KeychainCredentialCacheError.self) {
_ = try ClaudeCredentialStore.currentRecord()
}
}
}
@Test("Keychain errors render a readable message, not a struct dump")
func keychainErrorMessageIsReadable() {
let unavailable = KeychainCredentialCacheError.unavailable(
service: ClaudeCredentialStore.ourKeychainService,
status: errSecInteractionNotAllowed
)
let text = unavailable.localizedDescription
#expect(text.contains("Keychain unavailable"))
// AppStore renders errors via localizedDescription; a struct dump would
// read "unavailable(service:" and leak the raw item name.
#expect(!text.contains("unavailable(service:"))
#expect(!text.contains(ClaudeCredentialStore.ourKeychainService))
}
// MARK: - Recency between Keychain item and legacy file
@Test("newer legacy file beats an older Keychain item and is then unlinked")
func newerLegacyFileWins() throws {
try withHarness { harness in
// Keychain item from an old build: already expired.
try ClaudeCredentialStore.writeOurCache(record: .init(
accessToken: "cb-stale-keychain-token",
refreshToken: nil,
expiresAt: Date(timeIntervalSince1970: 1_700_000_000),
rateLimitTier: "default"
))
// Legacy file written far later by the pre-migration build.
try writeLegacyClaude0644(record: .init(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
))
ClaudeCredentialStore.isBootstrapCompleted = true
ClaudeCredentialStore.clearMemoryCacheForTesting()
let record = try #require(try ClaudeCredentialStore.currentRecord())
#expect(record.accessToken == accessSentinel)
#expect(record.refreshToken == nil)
#expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
let keys = harness.fakeKeychain.storedKeys(
service: ClaudeCredentialStore.ourKeychainService,
account: ClaudeCredentialStore.ourKeychainAccount
)
#expect(keys?.contains("refreshToken") != true)
}
}
@Test("older legacy file loses to a newer Keychain item and is unlinked")
func olderLegacyFileLoses() throws {
try withHarness { _ in
try ClaudeCredentialStore.writeOurCache(record: .init(
accessToken: accessSentinel,
refreshToken: nil,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
))
try writeLegacyClaude0644(record: .init(
accessToken: "cb-stale-file-token",
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_700_000_000),
rateLimitTier: "default"
))
ClaudeCredentialStore.isBootstrapCompleted = true
ClaudeCredentialStore.clearMemoryCacheForTesting()
let record = try #require(try ClaudeCredentialStore.currentRecord())
#expect(record.accessToken == accessSentinel)
#expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
}
}
@Test("Codex prefers the legacy file with the later lastRefresh")
func codexNewerLegacyFileWins() throws {
try withHarness { _ in
try CodexCredentialStore.writeOurCache(record: .init(
accessToken: "cb-stale-codex-access",
refreshToken: "cb-stale-codex-refresh",
idToken: nil,
accountId: accountSentinel,
expiresAt: nil,
lastRefresh: Date(timeIntervalSince1970: 1_700_000_000)
))
try writeLegacyCodex0644(record: .init(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
idToken: idSentinel,
accountId: accountSentinel,
expiresAt: nil,
lastRefresh: Date(timeIntervalSince1970: 1_800_000_000)
))
CodexCredentialStore.isBootstrapCompleted = true
CodexCredentialStore.clearMemoryCacheForTesting()
let record = try #require(try CodexCredentialStore.currentRecord())
#expect(record.refreshToken == refreshSentinel)
#expect(!FileManager.default.fileExists(atPath: CodexCredentialStore.cacheFileURL().path))
}
}
@Test("corrupt Keychain plus valid legacy repairs the CodeBurn item")
func corruptKeychainRepairedFromLegacy() throws {
try withHarness { harness in
try harness.fakeKeychain.upsert(
service: ClaudeCredentialStore.ourKeychainService,
account: ClaudeCredentialStore.ourKeychainAccount,
data: Data("%not-json%".utf8)
)
let legacy = ClaudeCredentialStore.CredentialRecord(
accessToken: accessSentinel,
refreshToken: refreshSentinel,
expiresAt: Date(timeIntervalSince1970: 1_900_000_000),
rateLimitTier: "default"
)
try writeLegacyClaude0644(record: legacy)
ClaudeCredentialStore.isBootstrapCompleted = true
let repaired = try #require(try ClaudeCredentialStore.currentRecord())
#expect(repaired.accessToken == accessSentinel)
#expect(repaired.refreshToken == nil)
#expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path))
let object = harness.fakeKeychain.storedJSONObject(
service: ClaudeCredentialStore.ourKeychainService,
account: ClaudeCredentialStore.ourKeychainAccount
)
#expect(object?.keys.contains("refreshToken") != true)
}
}
}

View file

@ -0,0 +1,97 @@
import Foundation
import Security
import Testing
@testable import CodeBurnMenubar
/// The ONLY test that touches a real Keychain. Everything else in the suite runs
/// against `InMemoryKeychainCredentialCache`.
///
/// It writes to a throwaway service name (`menubar.selftest.oauth.v1`) that no
/// build ever reads, never touches the Claude/Codex production items, and deletes
/// what it created. If the login Keychain is locked or unavailable headless CI,
/// SSH session, no login Keychain the whole suite is SKIPPED rather than failed;
/// look for "live Keychain unavailable" in the output to tell a skip from a pass.
@Suite("Live Keychain adapter", .serialized)
struct LiveKeychainCredentialCacheTests {
private static let service = "org.agentseal.codeburn.menubar.selftest.oauth.v1"
private static let account = "selftest"
/// True when this machine can round-trip a generic password right now.
private static let isAvailable: Bool = {
let live = LiveKeychainCredentialCache()
do {
try live.upsert(service: service, account: account, data: Data("probe".utf8))
_ = try live.read(service: service, account: account)
try live.delete(service: service, account: account)
return true
} catch {
try? live.delete(service: service, account: account)
return false
}
}()
@Test("live adapter round-trips write → read → update → delete")
func liveRoundTrip() throws {
guard Self.isAvailable else {
print("SKIP: live Keychain unavailable on this host")
return
}
let live = LiveKeychainCredentialCache()
defer { try? live.delete(service: Self.service, account: Self.account) }
#expect(try live.read(service: Self.service, account: Self.account) == nil)
try live.upsert(service: Self.service, account: Self.account, data: Data(#"{"v":1}"#.utf8))
let first = try #require(try live.read(service: Self.service, account: Self.account))
#expect(String(data: first, encoding: .utf8) == #"{"v":1}"#)
// upsert must update in place, not duplicate.
try live.upsert(service: Self.service, account: Self.account, data: Data(#"{"v":2}"#.utf8))
let second = try #require(try live.read(service: Self.service, account: Self.account))
#expect(String(data: second, encoding: .utf8) == #"{"v":2}"#)
try live.delete(service: Self.service, account: Self.account)
#expect(try live.read(service: Self.service, account: Self.account) == nil)
// Deleting an absent item is success, so disconnect stays idempotent.
#expect(throws: Never.self) {
try live.delete(service: Self.service, account: Self.account)
}
}
/// Documents the measured behaviour that motivates the pre-flight lock check.
/// The locked-keychain case itself is deliberately NOT exercised at runtime:
/// reproducing it requires a keychain operation that raises a password panel
/// on the tester's screen. Measured once by hand on macOS 15 against a
/// throwaway keychain: with the keychain locked, `SecItemCopyMatching` blocks
/// on an unlock panel even when the query carries
/// `kSecUseAuthenticationUI: Fail` or a non-interactive `LAContext` both
/// govern the data-protection keychain, not file-keychain unlocking. Skipping
/// the read while locked is therefore the only reliable suppression.
@Test("unavailable statuses are classified as transient, not as a missing item")
func unavailableClassification() {
#expect(KeychainCredentialCacheError.isUnavailable(errSecInteractionNotAllowed))
#expect(KeychainCredentialCacheError.isUnavailable(errSecAuthFailed))
#expect(KeychainCredentialCacheError.isUnavailable(errSecUserCanceled))
#expect(KeychainCredentialCacheError.isUnavailable(errSecInteractionRequired))
// errSecItemNotFound is a real miss and must never be treated as transient.
#expect(!KeychainCredentialCacheError.isUnavailable(errSecItemNotFound))
#expect(!KeychainCredentialCacheError.isUnavailable(errSecDecode))
}
@Test("live reads never block on an interactive prompt")
func liveReadIsNonInteractive() throws {
guard Self.isAvailable else {
print("SKIP: live Keychain unavailable on this host")
return
}
let live = LiveKeychainCredentialCache()
defer { try? live.delete(service: Self.service, account: Self.account) }
try live.upsert(service: Self.service, account: Self.account, data: Data("x".utf8))
// The read carries a non-interactive LAContext, so it either returns or
// fails fast. A prompt would park this call until a human dismissed it.
let start = Date()
_ = try? live.read(service: Self.service, account: Self.account)
#expect(Date().timeIntervalSince(start) < 5)
}
}