From fa7f53fee32089e50491c0a7b1f7d2d7e76c918f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:50:21 +0900 Subject: [PATCH] Bound usage.jsonl growth with a size cap and single-archive rotation --- src/lib/config-ownership.ts | 1 + src/usage/log.ts | 30 +++++++++++++++++++++++++++++- tests/usage/usage-log.test.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index 8500b47f7dd..bba8c33e008 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -76,6 +76,7 @@ const INITIAL_OWNED_PATHS = [ "update-job.json", "usage-debug.jsonl", "usage.jsonl", + "usage.jsonl.1", "version.json", "winsw", ] as const; diff --git a/src/usage/log.ts b/src/usage/log.ts index b1bd2193e02..9ea0c3effd0 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -1,5 +1,5 @@ import { createHash, type Hash } from "node:crypto"; -import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs"; +import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync, renameSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config"; import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing"; @@ -453,6 +453,13 @@ export function usageLogPath(configDir?: string): string { return join(configDir ?? getConfigDir(), "usage.jsonl"); } +const MAX_USAGE_LOG_BYTES = 64 * 1024 * 1024; +const MAX_USAGE_ENTRY_BYTES = 64 * 1024; + +function usageLogArchivePath(configDir?: string): string { + return `${usageLogPath(configDir)}.1`; +} + export function usageTotalTokens(usage: OcxUsage | undefined): number | undefined { return usageDisplayTotalTokens(usage); } @@ -913,6 +920,7 @@ function ensureUsageLogDir(now: number): void { const dir = getConfigDir(); if (usageLogPermissionCheckIsCurrent(ensuredUsageLogDir, dir, now)) return; recordOwnedConfigPath(dir, usageLogPath()); + recordOwnedConfigPath(dir, usageLogArchivePath()); mkdirSync(dir, { recursive: true, mode: 0o700 }); try { chmodSync(dir, 0o700); } catch { /* best-effort on platforms that ignore chmod */ } ensuredUsageLogDir = { path: dir, checkedAt: now }; @@ -924,6 +932,26 @@ export function appendUsageEntry(entry: PersistedUsageEntry): void { const now = Date.now(); const doAppend = (): void => { ensureUsageLogDir(now); + // Refuse anomalously large rows and rotate the active ledger before it can grow + // without bound. The single archive preserves recent history while bounding the + // total usage-log footprint to roughly twice MAX_USAGE_LOG_BYTES. + if (Buffer.byteLength(line) > MAX_USAGE_ENTRY_BYTES) return; + if (existsSync(path) && statSync(path).size + Buffer.byteLength(line) > MAX_USAGE_LOG_BYTES) { + const archive = usageLogArchivePath(); + rmSync(archive, { force: true }); + if (statSync(path).size <= MAX_USAGE_LOG_BYTES) { + renameSync(path, archive); + try { chmodSync(archive, 0o600); } catch { /* best-effort on platforms that ignore chmod */ } + } else { + // A legacy log may already exceed the new bound; do not preserve an + // arbitrarily large attacker-controlled file as the archive. + rmSync(path, { force: true }); + } + // The cached permission check described the rotated inode; the replacement + // file is created with mode 0o600 below, but never let the cache vouch for + // a file it did not observe. + ensuredUsageLogFile = null; + } const filePermissionsCurrent = usageLogPermissionCheckIsCurrent(ensuredUsageLogFile, path, now); appendFileSync(path, line, { encoding: "utf-8", mode: 0o600 }); if (!filePermissionsCurrent) { diff --git a/tests/usage/usage-log.test.ts b/tests/usage/usage-log.test.ts index 02c3b346750..fc7537427da 100644 --- a/tests/usage/usage-log.test.ts +++ b/tests/usage/usage-log.test.ts @@ -779,6 +779,40 @@ describe("usage log", () => { expect(usageLogPath()).toBe(join(testDir, "usage.jsonl")); }); + test("rotates a full usage log and keeps disk growth bounded", () => { + const path = usageLogPath(); + const fd = openSync(path, "w"); + truncateSync(fd, 64 * 1024 * 1024); + closeSync(fd); + + appendUsageEntry({ + requestId: "after-rotation", + timestamp: 1, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 1, + usageStatus: "unreported", + }); + + expect(statSync(`${path}.1`).size).toBe(64 * 1024 * 1024); + expect(readUsageEntries().map(entry => entry.requestId)).toEqual(["after-rotation"]); + }, STORE_BUDGET_MS); // same sparse >64 MiB fixture profile as the management-read test above. + + test("does not persist an oversized usage row", () => { + appendUsageEntry({ + requestId: "oversized", + timestamp: 1, + provider: "openai", + model: `gpt-${"x".repeat(64 * 1024)}`, + status: 400, + durationMs: 1, + usageStatus: "unreported", + }); + + expect(existsSync(usageLogPath())).toBe(false); + }); + test("appends secret-safe usage entries and reads them back", () => { appendUsageEntry({ requestId: "ocx-1",