Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib/config-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const INITIAL_OWNED_PATHS = [
"update-job.json",
"usage-debug.jsonl",
"usage.jsonl",
"usage.jsonl.1",
"version.json",
"winsw",
] as const;
Expand Down
30 changes: 29 additions & 1 deletion src/usage/log.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 };
Expand All @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions tests/usage/usage-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading