diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 9e1f4a51..3ae5bb25 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -380,7 +380,9 @@ including cached input and cache writes; fees and surcharges are not included. Use `--max-cost USD` to stop a scan, including its delegated workers, when its running cost exceeds the limit. Partial results are preserved. Requests -already in progress can finish above the limit. +already in progress can finish above the limit. Cost tracking accepts Codex +session events up to 1 MiB; an oversized event stops the scan because its +running cost can no longer be verified safely. Run `npx @openai/codex-security scan --help` or `npx @openai/codex-security bulk-scan --help` for the complete CLI references. diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 912ccd27..1481e9dc 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -28,7 +28,9 @@ interface ScanTokenUsage { interface SessionUsage { offset: number; - remainder: Buffer; + pendingLine: Buffer[]; + pendingLineBytes: number; + unreadable: boolean; threadId: string | null; parentThreadId: string | null; usage: ScanTokenUsage | null; @@ -56,6 +58,7 @@ const MODEL_PRICING_NANODOLLARS: Readonly> = { const COST_POLL_INTERVAL_MS = 100; const SESSION_READ_SIZE = 64 * 1_024; +const MAX_SESSION_EVENT_BYTES = 1 * 1_024 * 1_024; export class ScanCostTracker { readonly #options: ScanCostTrackerOptions; @@ -115,7 +118,9 @@ export class ScanCostTracker { if (session === undefined) { session = { offset: 0, - remainder: Buffer.alloc(0), + pendingLine: [], + pendingLineBytes: 0, + unreadable: false, threadId: null, parentThreadId: null, usage: null, @@ -187,6 +192,7 @@ async function readSessionUsage( path: string, session: SessionUsage, ): Promise { + if (session.unreadable) return; let file; try { file = await open(path, "r"); @@ -205,27 +211,54 @@ async function readSessionUsage( ); if (bytesRead === 0) return; session.offset += bytesRead; - const contents = Buffer.concat([ - session.remainder, - buffer.subarray(0, bytesRead), - ]); - let lineStart = 0; - while (true) { - const lineEnd = contents.indexOf(0x0a, lineStart); - if (lineEnd === -1) break; - readSessionEvent( - contents.subarray(lineStart, lineEnd).toString("utf8"), - session, - ); - lineStart = lineEnd + 1; + try { + readSessionChunk(buffer.subarray(0, bytesRead), session); + } catch (error) { + session.unreadable = true; + session.pendingLine = []; + session.pendingLineBytes = 0; + throw error; } - session.remainder = Buffer.from(contents.subarray(lineStart)); } } finally { await file.close(); } } +function readSessionChunk(contents: Buffer, session: SessionUsage): void { + let lineStart = 0; + while (lineStart < contents.length) { + const newline = contents.indexOf(0x0a, lineStart); + const lineEnd = newline === -1 ? contents.length : newline; + const fragment = contents.subarray(lineStart, lineEnd); + const lineBytes = session.pendingLineBytes + fragment.length; + if (lineBytes > MAX_SESSION_EVENT_BYTES) { + throw new Error("Codex session event exceeds the 1 MiB safety limit."); + } + + if (newline === -1) { + if (fragment.length > 0) { + session.pendingLine.push(Buffer.from(fragment)); + session.pendingLineBytes = lineBytes; + } + return; + } + + if (session.pendingLineBytes === 0) { + readSessionEvent(fragment.toString("utf8"), session); + } else { + if (fragment.length > 0) session.pendingLine.push(Buffer.from(fragment)); + readSessionEvent( + Buffer.concat(session.pendingLine, lineBytes).toString("utf8"), + session, + ); + session.pendingLine = []; + session.pendingLineBytes = 0; + } + lineStart = newline + 1; + } +} + function readSessionEvent(line: string, session: SessionUsage): void { if (line.length === 0) return; let event: unknown; diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 5e7d588b..9647d950 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -226,6 +226,62 @@ describe("live scan cost tracking", () => { }); }); + test("retains a bounded partial event across incremental reads", async () => { + const home = await codexHome(); + const path = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + }); + tracker.start("scan-thread"); + await tracker.refresh(); + + const event = JSON.stringify({ + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: 250, output_tokens: 20 }, + }, + }, + }); + const padding = " ".repeat(128 * 1_024); + await appendFile(path, `${padding}${event.slice(0, 40)}`); + expect((await tracker.refresh()).cost?.inputTokens).toBe(100); + + await appendFile(path, `${event.slice(40)}\n`); + expect((await tracker.stop()).cost?.inputTokens).toBe(250); + }); + + test("rejects and quarantines a session event larger than 1 MiB", async () => { + const home = await codexHome(); + const path = await writeSession(home, "scan-thread", { + input_tokens: 100, + output_tokens: 10, + }); + await appendFile(path, "x".repeat(1 * 1_024 * 1_024 + 1)); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-terra", + }); + tracker.start("scan-thread"); + + await expect(tracker.refresh()).rejects.toThrow( + "Codex session event exceeds the 1 MiB safety limit.", + ); + expect((await tracker.refresh()).cost).toEqual({ + model: "gpt-5.6-terra", + inputTokens: 100, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + outputTokens: 10, + estimatedUsd: 0.0004, + }); + }); + test("reports a changed running cost only once", async () => { const home = await codexHome(); await writeSession(home, "scan-thread", {