Skip to content
Merged
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
53 changes: 49 additions & 4 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,18 +852,61 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
};
}

function ensureUsageLogDir(): void {
// Bound hot-path filesystem hardening to once per second while ensuring an external mode
// widening cannot suppress write-triggered repair for the lifetime of the process.
const USAGE_LOG_PERMISSION_RECHECK_MS = 1_000;

type UsageLogPermissionCheck = {
path: string;
checkedAt: number;
};

let ensuredUsageLogDir: UsageLogPermissionCheck | null = null;
let ensuredUsageLogFile: UsageLogPermissionCheck | null = null;

function usageLogPermissionCheckIsCurrent(
check: UsageLogPermissionCheck | null,
path: string,
now: number,
): boolean {
return check?.path === path
&& now >= check.checkedAt
&& now - check.checkedAt < USAGE_LOG_PERMISSION_RECHECK_MS;
}

function ensureUsageLogDir(now: number): void {
const dir = getConfigDir();
if (usageLogPermissionCheckIsCurrent(ensuredUsageLogDir, dir, now)) return;
recordOwnedConfigPath(dir, usageLogPath());
mkdirSync(dir, { recursive: true, mode: 0o700 });
try { chmodSync(dir, 0o700); } catch { /* best-effort on platforms that ignore chmod */ }
ensuredUsageLogDir = { path: dir, checkedAt: now };
}

export function appendUsageEntry(entry: PersistedUsageEntry): void {
ensureUsageLogDir();
const line = `${JSON.stringify(normalizeUsageEntry(entry))}\n`;
const path = usageLogPath();
appendFileSync(path, `${JSON.stringify(normalizeUsageEntry(entry))}\n`, { encoding: "utf-8", mode: 0o600 });
try { chmodSync(path, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
const now = Date.now();
const doAppend = (): void => {
ensureUsageLogDir(now);
const filePermissionsCurrent = usageLogPermissionCheckIsCurrent(ensuredUsageLogFile, path, now);
appendFileSync(path, line, { encoding: "utf-8", mode: 0o600 });
if (!filePermissionsCurrent) {
try { chmodSync(path, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
ensuredUsageLogFile = { path, checkedAt: now };
}
};
try {
doAppend();
} catch (error: any) {
if (error?.code === "ENOENT") {
ensuredUsageLogDir = null;
ensuredUsageLogFile = null;
doAppend();
return;
}
throw error;
}
}

export type UsageLogRevision = {
Expand Down Expand Up @@ -1057,6 +1100,8 @@ export function resetUsageReadCacheForTests(): void {
managementUsageReadInflight?.abort.abort();
managementUsageReadInflight = null;
retainedUsageSnapshot = null;
ensuredUsageLogDir = null;
ensuredUsageLogFile = null;
}

function readExactly(fd: number, length: number, position: number): Buffer | null {
Expand Down
7 changes: 6 additions & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,12 @@ status, so an unexpected management response cannot add raw upstream material.

> Decision record: [ADR-0078](decisions/ADR-0078-usage-accounting.md)

`src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`.
`src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`
inside an owner-only `0o700` directory. Consecutive appends reuse the directory and permission
check for at most one second; the first append at or after that boundary attempts to reapply both
modes, and an `ENOENT` append invalidates the cache and recreates the path immediately. This is a
bounded, write-triggered repair of externally widened POSIX modes, not continuous filesystem
monitoring or protection against another process changing the path again after the check.
An opt-in shadow-call rewrite persists the bounded, redacted original helper model as
`shadowCallRewrittenFrom`, so helper traffic remains identifiable after restart without storing
request content or inferring a helper subtype from timing.
Expand Down
5 changes: 4 additions & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,10 @@ fingerprint mismatch are named separately rather than all reported as a missing
Codex display-cache expiry, retained main-policy evidence, and reset history follow the
[quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history).

Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger.
Usage consumers preserve positive incomplete-history metadata as specified in
[usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented
as a complete ledger. The same contract owns `src/usage/log.ts` append-path permission rechecks and
their bounded cache.

Connected `ocx usage` reads `/v1/usage` through `src/client/hub-client.ts`, using its enrolled data key and checking connection/token ownership before and after the read. It reports hub/client scope and never substitutes local totals on failure. Standalone commands retain their management endpoint.

Expand Down
102 changes: 101 additions & 1 deletion tests/usage/usage-log.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { STORE_BUDGET_MS } from "../helpers/test-budget";
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, truncateSync, writeFileSync, writeSync } from "node:fs";
import { chmodSync, closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, statSync, truncateSync, writeFileSync, writeSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
Expand Down Expand Up @@ -1012,4 +1012,104 @@ describe("usage log", () => {

expect(readRecentUsageEntries(1)).toEqual([]);
}, STORE_BUDGET_MS);

test("appendUsageEntry avoids redundant mkdirSync and chmodSync on consecutive calls", async () => {
const nodeFs = await import("node:fs");
const mkdirSpy = spyOn(nodeFs, "mkdirSync");
const chmodSpy = spyOn(nodeFs, "chmodSync");
// Pinned so the count measures the cache and not the runner's scheduling: five
// appends that happen to straddle the one-second boundary would legitimately
// harden twice, and a saturated CI runner can take that long.
const clock = spyOn(Date, "now").mockReturnValue(1_700_000_000_000);

try {
for (let i = 0; i < 5; i++) {
appendUsageEntry({
requestId: `ocx-perf-${i}`,
timestamp: Date.now(),
provider: "openai",
model: "gpt-4o",
status: 200,
durationMs: 10,
usageStatus: "unreported",
});
}

expect(mkdirSpy.mock.calls.length).toBe(1);
// One for the directory, one for the file. Not five.
expect(chmodSpy.mock.calls.length).toBe(2);
} finally {
clock.mockRestore();
mkdirSpy.mockRestore();
chmodSpy.mockRestore();
}
});

test.skipIf(process.platform === "win32")(
"appendUsageEntry re-narrows externally widened permissions after the bounded cache window",
() => {
const now = Date.now();
const clock = spyOn(Date, "now").mockReturnValue(now);
try {
appendUsageEntry({
requestId: "ocx-permission-initial",
timestamp: now,
provider: "openai",
model: "gpt-4o",
status: 200,
durationMs: 10,
usageStatus: "unreported",
});
chmodSync(testDir, 0o755);
chmodSync(usageLogPath(), 0o644);

clock.mockReturnValue(now + 1_000);
appendUsageEntry({
requestId: "ocx-permission-recheck",
timestamp: now + 1_000,
provider: "openai",
model: "gpt-4o",
status: 200,
durationMs: 11,
usageStatus: "unreported",
});

expect(statSync(testDir).mode & 0o777).toBe(0o700);
expect(statSync(usageLogPath()).mode & 0o777).toBe(0o600);
} finally {
clock.mockRestore();
}
},
);

test("appendUsageEntry recovers cleanly on ENOENT if usage directory is deleted between calls", () => {
const entry1: PersistedUsageEntry = {
requestId: "ocx-enoent-1",
timestamp: Date.now(),
provider: "openai",
model: "gpt-4o",
status: 200,
durationMs: 10,
usageStatus: "unreported",
};
appendUsageEntry(entry1);

// Simulate directory deletion by log rotation / cleanup while process is running
rmSync(testDir, { recursive: true, force: true });
expect(existsSync(testDir)).toBe(false);

const entry2: PersistedUsageEntry = {
requestId: "ocx-enoent-2",
timestamp: Date.now(),
provider: "openai",
model: "gpt-4o",
status: 200,
durationMs: 12,
usageStatus: "unreported",
};
expect(() => appendUsageEntry(entry2)).not.toThrow();
const readBack = readRecentUsageEntries(10);
expect(readBack.length).toBe(1);
expect(readBack[0].requestId).toBe("ocx-enoent-2");
});
});
Loading