From b0131f698c946cbe0225e5f02eda21ff627db63d Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 17 May 2026 07:18:58 -0700 Subject: [PATCH] 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. --- .../Data/ClaudeCredentialStore.swift | 29 ++++++++++++------- .../Data/CodexCredentialStore.swift | 24 ++++++++++----- src/daily-cache.ts | 18 ++++-------- src/main.ts | 22 +++++++++++--- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift index e47db7b..286c195 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -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) { diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift index cffae7b..e5ddb0e 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -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) { diff --git a/src/daily-cache.ts b/src/daily-cache.ts index ab43017..0947b06 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -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 = { diff --git a/src/main.ts b/src/main.ts index fd88cf1..485aa4c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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