diff --git a/src/usage/log.ts b/src/usage/log.ts index aadeb1648bb..523f1c209d3 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -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 = { @@ -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 { diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5d992a10468..edbce50fb38 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -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. diff --git a/structure/runtime.md b/structure/runtime.md index ed2bffbf75f..94a45b56df3 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -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. diff --git a/tests/usage/usage-log.test.ts b/tests/usage/usage-log.test.ts index 9cd97855a6a..536f4c446f5 100644 --- a/tests/usage/usage-log.test.ts +++ b/tests/usage/usage-log.test.ts @@ -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 { @@ -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"); + }); });