mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 12:20:43 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
Two passes of validators across CLI accuracy, dashboard UX, menubar Swift, performance, security, and end-to-end smoke tests on real session data. Data-correctness fixes: - parseLocalDate rejects month/day overflow. JS Date silently rolled Feb 31 to Mar 3, so --from 2026-02-31 --to 2026-03-15 quietly dropped sessions on Feb 28 - Mar 2. Now throws "Invalid date" with a clear reason. Leap-day case covered (2024-02-29 valid, 2025-02-29 rejected). - CSV/JSON exports use the active currency's natural decimal places. The previous round2 helper produced ¥412.37 in CSV while the dashboard rendered ¥412 — finance teams comparing the two surfaces saw a discrepancy. New roundForActiveCurrency consults Intl.NumberFormat for the right precision (0 for JPY/KRW/CLP, 2 for USD/EUR, etc). - Copilot toolRequests is Array.isArray-guarded in both modern and legacy event branches. Previously a corrupt session with toolRequests=null or a string aborted the whole file's parse loop and silently dropped every legitimate call after it. - Codex token_count dedup uses a null sentinel for prevCumulativeTotal so the first event is never confused with a duplicate. Sessions that emit only last_token_usage (no total_token_usage) report cumulativeTotal=0 on every event; with the previous 0-initialized prev, the first event matched the dedup guard and was dropped. - LiteLLM pricing values are clamped to [0, 1] per token via safePerTokenRate. Defense in depth against a tampered upstream JSON shipping negative or absurdly large per-token costs that would otherwise propagate into all cost totals. Performance: - Cursor SQLite parse no longer pegs at minutes on multi-GB DBs. Two changes: per-conversation user-message buffer uses an index pointer instead of Array.shift() (which was O(n) per call); and a real ROWID cutoff via subquery limits the scan to the most recent 250k bubbles with a stderr warning so power users get a partial report rather than a stalled CLI. - Spawned codeburn CLI subprocesses are terminated when the calling Task is cancelled. Without this, rapid period/provider tab clicks in the menubar cancelled the Task but left the subprocess running to completion, piling up zombie processes. UX: - Dashboard period switch flips to loading and clears projects synchronously before reloadData runs, eliminating the frame where the new period label rendered over the old period's projects. - Optimize findings tab paginates 3-at-a-time with j/k scroll. With 4 new detectors plus 7 originals, 8-10 findings * 6 lines was scrolling the StatusBar off the alt buffer top. - Custom --from/--to ranges hide the period tab strip and disable the 1-5 / arrow keys so a stray period press no longer abandons the user's explicit range. A "Custom range: X to Y" banner replaces the tab strip. - OpenCode storage-format warning is per-table-set, rate-limited to once per process, and points the user at OpenCode's migration step or the issue tracker. The previous all-or-nothing check fired the generic "format not recognized" string for any schema mismatch. Menubar / OAuth: - Both Claude and Codex bootstrap (Reconnect button) now honour the usageBlockedUntil 429 backoff that refreshIfBootstrapped respects. Spamming Reconnect during sustained rate-limit windows previously hammered the upstream endpoint on every click. - Codex Retry-After HTTP header is parsed (delta-seconds plus IMF-fixdate fallback) so we don't over-back-off when ChatGPT tells us a shorter window than our 5-minute floor. - Both credential cache files are written via SafeFile.write (O_CREAT | O_EXCL | O_NOFOLLOW with explicit 0600) so there is no race window where the temp file briefly exists at default umask, and a symlink at the destination cannot redirect the write. Reads now route through SafeFile.read with a 64 KiB cap, closing the symlink-follow gap on Data(contentsOf:). CI signal: - TypeScript strict typecheck (tsc --noEmit) is now zero errors. The six errors in src/providers/copilot.ts came from a discriminated-union catch-all branch whose `data: Record<string, unknown>` shape TS picked over the specific event branches when narrowing on `type`. Removed the catch-all; runtime falls through unknown event types via the existing if/else chain. Tests added: 16 new (now 555 total) - date-range-filter: month/day/year overflow rejection, leap-day correctness - currency-rounding: convertCost no-rounding contract, roundForActiveCurrency for USD/JPY/KRW/EUR - providers/copilot: malformed toolRequests does not abort the parse - providers/cursor-bubble-dedup: re-parse after token mutation does not double-count, single parse yields one call per bubble - providers/codex: first event with cumulativeTotal=0 not dropped, consecutive zero-cumulative duplicates still deduped
287 lines
11 KiB
Swift
287 lines
11 KiB
Swift
import Foundation
|
|
|
|
/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors
|
|
/// 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
|
|
/// we keep using rotated tokens after refresh.
|
|
enum CodexCredentialStore {
|
|
private static let bootstrapCompletedKey = "codeburn.codex.bootstrapCompleted"
|
|
private static let inMemoryTTL: TimeInterval = 5 * 60
|
|
private static let proactiveRefreshMargin: TimeInterval = 5 * 60
|
|
|
|
private static let oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
private static let refreshURL = URL(string: "https://auth.openai.com/oauth/token")!
|
|
private static let codexAuthPath = ".codex/auth.json"
|
|
private static let maxCredentialBytes = 64 * 1024
|
|
|
|
private static let cacheFilename = "codex-credentials.v1.json"
|
|
|
|
private static let lock = NSLock()
|
|
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
|
|
|
struct CachedRecord {
|
|
let record: CredentialRecord
|
|
let cachedAt: Date
|
|
|
|
var isFresh: Bool { Date().timeIntervalSince(cachedAt) < CodexCredentialStore.inMemoryTTL }
|
|
}
|
|
|
|
struct CredentialRecord: Codable, Equatable {
|
|
let accessToken: String
|
|
let refreshToken: String
|
|
let idToken: String?
|
|
let accountId: String?
|
|
let expiresAt: Date?
|
|
}
|
|
|
|
enum StoreError: Error, LocalizedError {
|
|
case bootstrapNoSource
|
|
case bootstrapDecodeFailed
|
|
case bootstrapNotChatGPT // user is on API-key mode; we need ChatGPT mode for quota
|
|
case fileWriteFailed(String)
|
|
case refreshHTTPError(Int, String?)
|
|
case refreshNetworkError(Error)
|
|
case refreshDecodeFailed
|
|
case noRefreshToken
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .bootstrapNoSource:
|
|
return "No Codex credentials found at ~/.codex/auth.json. Run `codex` to sign in."
|
|
case .bootstrapDecodeFailed:
|
|
return "Codex credentials are malformed."
|
|
case .bootstrapNotChatGPT:
|
|
return "Codex is in API-key mode; live quota tracking is only available for ChatGPT subscriptions."
|
|
case let .fileWriteFailed(message):
|
|
return "Could not write to local cache: \(message)"
|
|
case let .refreshHTTPError(code, body):
|
|
return "Codex token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
|
|
case let .refreshNetworkError(err):
|
|
return "Codex token refresh network error: \(err.localizedDescription)"
|
|
case .refreshDecodeFailed:
|
|
return "Codex token refresh response was malformed."
|
|
case .noRefreshToken:
|
|
return "No refresh token available; reconnect required."
|
|
}
|
|
}
|
|
|
|
/// True when the user must take action: rerun `codex` to re-authenticate
|
|
/// or switch from API-key to ChatGPT mode. Drives the red Reconnect path.
|
|
var isTerminal: Bool {
|
|
if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 {
|
|
let lower = body?.lowercased() ?? ""
|
|
if lower.contains("refresh_token_expired") ||
|
|
lower.contains("refresh_token_reused") ||
|
|
lower.contains("refresh_token_invalidated") ||
|
|
lower.contains("invalid_grant")
|
|
{
|
|
return true
|
|
}
|
|
return true
|
|
}
|
|
switch self {
|
|
case .noRefreshToken, .bootstrapNotChatGPT, .bootstrapNoSource: return true
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Bootstrap state
|
|
|
|
static var isBootstrapCompleted: Bool {
|
|
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
|
|
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
|
|
}
|
|
|
|
static func resetBootstrap() {
|
|
lock.withLock { memoryCache = nil }
|
|
deleteOurCache()
|
|
isBootstrapCompleted = false
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
@discardableResult
|
|
static func bootstrap() throws -> CredentialRecord {
|
|
let record = try readCodexAuth()
|
|
try writeOurCache(record: record)
|
|
isBootstrapCompleted = true
|
|
cacheInMemory(record)
|
|
return record
|
|
}
|
|
|
|
static func currentRecord() throws -> CredentialRecord? {
|
|
guard isBootstrapCompleted else { return nil }
|
|
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
|
|
return cached.record
|
|
}
|
|
if let stored = try readOurCache() {
|
|
cacheInMemory(stored)
|
|
return stored
|
|
}
|
|
isBootstrapCompleted = false
|
|
return nil
|
|
}
|
|
|
|
static func freshAccessToken() async throws -> String? {
|
|
guard let record = try currentRecord() else { return nil }
|
|
if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin {
|
|
let updated = try await refreshAndPersist(record: record)
|
|
return updated.accessToken
|
|
}
|
|
return record.accessToken
|
|
}
|
|
|
|
static func refreshAfter401() async throws -> String {
|
|
guard let record = try currentRecord() else { throw StoreError.noRefreshToken }
|
|
let updated = try await refreshAndPersist(record: record)
|
|
return updated.accessToken
|
|
}
|
|
|
|
// MARK: - Bootstrap source: ~/.codex/auth.json
|
|
|
|
private static func readCodexAuth() throws -> CredentialRecord {
|
|
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath)
|
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
|
throw StoreError.bootstrapNoSource
|
|
}
|
|
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
|
struct Root: Decodable {
|
|
let auth_mode: String?
|
|
let tokens: Tokens?
|
|
}
|
|
struct Tokens: Decodable {
|
|
let access_token: String?
|
|
let refresh_token: String?
|
|
let id_token: String?
|
|
let account_id: String?
|
|
}
|
|
do {
|
|
let root = try JSONDecoder().decode(Root.self, from: data)
|
|
// Live quota is only meaningful for ChatGPT-mode auth. API-key users
|
|
// have a different billing surface (/v1/usage) which we do not yet
|
|
// implement here.
|
|
guard root.auth_mode == "chatgpt" else {
|
|
throw StoreError.bootstrapNotChatGPT
|
|
}
|
|
guard let tokens = root.tokens,
|
|
let access = tokens.access_token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
let refresh = tokens.refresh_token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!access.isEmpty, !refresh.isEmpty
|
|
else {
|
|
throw StoreError.bootstrapDecodeFailed
|
|
}
|
|
return CredentialRecord(
|
|
accessToken: access,
|
|
refreshToken: refresh,
|
|
idToken: tokens.id_token,
|
|
accountId: tokens.account_id,
|
|
expiresAt: nil // Codex CLI does not record expiresAt in auth.json
|
|
)
|
|
} catch let err as StoreError {
|
|
throw err
|
|
} catch {
|
|
throw StoreError.bootstrapDecodeFailed
|
|
}
|
|
}
|
|
|
|
// MARK: - Local cache file
|
|
|
|
private static func cacheFileURL() -> URL {
|
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
|
return support
|
|
.appendingPathComponent("CodeBurn", isDirectory: true)
|
|
.appendingPathComponent(cacheFilename)
|
|
}
|
|
|
|
private static func readOurCache() throws -> CredentialRecord? {
|
|
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)
|
|
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
|
|
}
|
|
|
|
private static func writeOurCache(record: CredentialRecord) throws {
|
|
let url = cacheFileURL()
|
|
let data = try JSONEncoder().encode(record)
|
|
do {
|
|
// SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW
|
|
// and the explicit 0600 mode in a single syscall — no race window
|
|
// where the file briefly exists at default umask, and no chance of
|
|
// following a malicious symlink at the destination path.
|
|
try SafeFile.write(data, to: url.path, mode: 0o600)
|
|
} catch {
|
|
throw StoreError.fileWriteFailed(String(describing: error))
|
|
}
|
|
}
|
|
|
|
private static func deleteOurCache() {
|
|
try? FileManager.default.removeItem(at: cacheFileURL())
|
|
}
|
|
|
|
private static func cacheInMemory(_ record: CredentialRecord) {
|
|
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
|
|
}
|
|
|
|
// MARK: - Refresh
|
|
|
|
private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord {
|
|
guard !record.refreshToken.isEmpty else { throw StoreError.noRefreshToken }
|
|
|
|
var request = URLRequest(url: refreshURL)
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = 30
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
let body: [String: String] = [
|
|
"client_id": oauthClientID,
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": record.refreshToken,
|
|
"scope": "openid profile email",
|
|
]
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
do {
|
|
(data, response) = try await URLSession.shared.data(for: request)
|
|
} catch {
|
|
throw StoreError.refreshNetworkError(error)
|
|
}
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw StoreError.refreshHTTPError(-1, nil)
|
|
}
|
|
guard http.statusCode == 200 else {
|
|
let body = String(data: data, encoding: .utf8)
|
|
throw StoreError.refreshHTTPError(http.statusCode, body)
|
|
}
|
|
|
|
struct RefreshResponse: Decodable {
|
|
let access_token: String
|
|
let refresh_token: String?
|
|
let id_token: String?
|
|
let expires_in: Int?
|
|
}
|
|
guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else {
|
|
throw StoreError.refreshDecodeFailed
|
|
}
|
|
|
|
let updated = CredentialRecord(
|
|
accessToken: decoded.access_token,
|
|
refreshToken: decoded.refresh_token ?? record.refreshToken,
|
|
idToken: decoded.id_token ?? record.idToken,
|
|
accountId: record.accountId,
|
|
expiresAt: decoded.expires_in.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt
|
|
)
|
|
cacheInMemory(updated)
|
|
do {
|
|
try writeOurCache(record: updated)
|
|
} catch {
|
|
NSLog("CodeBurn: codex cache write failed during refresh rotation: %@", String(describing: error))
|
|
}
|
|
return updated
|
|
}
|
|
}
|