mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-20 17:47:19 +00:00
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:
parent
9ad137f2b8
commit
b0131f698c
4 changed files with 58 additions and 35 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
22
src/main.ts
22
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue