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