Store credential cache in file instead of keychain, use cache for per-provider menubar

Credential cache: switched from keychain to file-based storage under
Application Support. Ad-hoc signed builds invalidate keychain ACLs on
every rebuild, causing repeated macOS password prompts. Existing
keychain entries are migrated to file on first read, then deleted.

Per-provider menubar: the Codex/Claude/etc tabs previously re-parsed
all sessions from scratch (22s). Now parses only today with the
provider filter and uses the daily cache for historical days, matching
the fast path the All tab already uses.

Daily cache bumped to v7 to force a clean rebuild after pricing and
provider changes since v6.
This commit is contained in:
iamtoruk 2026-05-17 07:18:58 -07:00
parent 9ad137f2b8
commit b0131f698c
4 changed files with 58 additions and 35 deletions

View file

@ -279,26 +279,29 @@ enum ClaudeCredentialStore {
}
private static func readOurCache() throws -> CredentialRecord? {
if let record = try readOurKeychainCache() {
return record
// Migrate: if credentials exist in keychain from a previous build, move to file.
if let keychainRecord = try? readOurKeychainCache() {
try? writeOurFileCache(record: keychainRecord)
deleteOurKeychainCache()
return keychainRecord
}
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
// Route through SafeFile.read so we lstat for symlinks before opening
// and bound the read with maxCredentialBytes. Without this, an
// attacker who can plant a symlink in ~/Library/Application Support/
// CodeBurn/ between disconnect and reconnect could redirect our read
// to /dev/zero (unbounded memory) or another file the user owns.
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
try? writeOurKeychainCache(record: record)
try? FileManager.default.removeItem(at: url)
return record
}
private static func writeOurCache(record: CredentialRecord) throws {
try writeOurKeychainCache(record: record)
try writeOurFileCache(record: record)
}
private static func writeOurFileCache(record: CredentialRecord) throws {
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])
}
private static func readOurKeychainCache() throws -> CredentialRecord? {
@ -345,13 +348,17 @@ enum ClaudeCredentialStore {
}
private static func deleteOurCache() {
deleteOurKeychainCache()
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func deleteOurKeychainCache() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ourKeychainService,
kSecAttrAccount as String: ourKeychainAccount,
]
SecItemDelete(query as CFDictionary)
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func cacheInMemory(_ record: CredentialRecord) {

View file

@ -201,22 +201,28 @@ enum CodexCredentialStore {
}
private static func readOurCache() throws -> CredentialRecord? {
if let record = try readOurKeychainCache() {
return record
if let keychainRecord = try? readOurKeychainCache() {
try? writeOurFileCache(record: keychainRecord)
deleteOurKeychainCache()
return keychainRecord
}
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
// Symlink-defense + size cap (same hardening as ClaudeCredentialStore).
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
try? writeOurKeychainCache(record: record)
try? FileManager.default.removeItem(at: url)
return record
}
private static func writeOurCache(record: CredentialRecord) throws {
try writeOurKeychainCache(record: record)
try writeOurFileCache(record: record)
}
private static func writeOurFileCache(record: CredentialRecord) throws {
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])
}
private static func readOurKeychainCache() throws -> CredentialRecord? {
@ -263,13 +269,17 @@ enum CodexCredentialStore {
}
private static func deleteOurCache() {
deleteOurKeychainCache()
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func deleteOurKeychainCache() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ourKeychainService,
kSecAttrAccount as String: ourKeychainAccount,
]
SecItemDelete(query as CFDictionary)
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func cacheInMemory(_ record: CredentialRecord) {

View file

@ -5,19 +5,11 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'
// Bumped to 6 alongside the Claude 1-hour cache-write pricing fix: prior
// daily entries priced all Claude cache writes at the 5-minute rate, so
// cached historical cost/model/provider/category totals would remain
// under-reported unless discarded and recomputed from raw sessions.
export const DAILY_CACHE_VERSION = 6
// MIN_SUPPORTED_VERSION bumped to 6 too. The migration path
// (isMigratableCache + migrateDays) only fills in missing default fields;
// it does NOT recompute the providers / categories / models rollups from
// session data, because those raw sessions are not stored in the cache.
// So a migrated v5 cache would carry forward stale pricing totals for
// the full cache retention window. Setting the floor to 6 forces older
// caches to be discarded and recomputed cleanly.
const MIN_SUPPORTED_VERSION = 6
// Bumped to 7: new providers (Codebuff, Mistral Vibe, Kimi, Cline) and
// the per-provider menubar path now reads historical cost from the cache.
// Stale entries computed by older binaries may carry incorrect totals.
export const DAILY_CACHE_VERSION = 7
const MIN_SUPPORTED_VERSION = 7
const DAILY_CACHE_FILENAME = 'daily-cache.json'
export type DailyEntry = {

View file

@ -482,10 +482,24 @@ program
scanProjects = todayProjects
scanRange = periodInfo.range
} else {
const projects = fp(await parseAllSessions(periodInfo.range, pf))
currentData = buildPeriodData(periodInfo.label, projects)
scanProjects = projects
scanRange = periodInfo.range
// Per-provider: parse only today (fast), use cache for historical days.
// The cache stores per-provider cost+calls per day, so we extract those
// and combine with today's fully-parsed provider data.
const todayProviderProjects = fp(await parseAllSessions(todayRange, pf))
const todayData = buildPeriodData(periodInfo.label, todayProviderProjects)
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
let histCost = 0, histCalls = 0
for (const d of historicalDays) {
const prov = d.providers[pf]
if (prov) { histCost += prov.cost; histCalls += prov.calls }
}
currentData = {
...todayData,
cost: todayData.cost + histCost,
calls: todayData.calls + histCalls,
}
scanProjects = todayProviderProjects
scanRange = todayRange
}
// PROVIDERS