fix(codex): guard non-string timestamp and model on the parse path

Follow-up to #881. Structural discovery admits third-party rollouts whose
schema is unverified. Two unchecked JSON.parse fields still reached string ops
on the parse path: an unparseable timestamp threw RangeError out of the
fork-cutoff Date math, and a non-string model threw TypeError from calculateCost
(.replace). Either sank that session's usage to zero. Skip the fork cutoff for
an unparseable timestamp, and only adopt a string model (falling back to a real
model otherwise) so the session is counted instead of silently reading zero.
This commit is contained in:
AgentSeal 2026-08-04 00:14:28 +02:00
parent 733003df35
commit 4ff3497eb8
2 changed files with 67 additions and 8 deletions

View file

@ -554,12 +554,19 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
return sources
}
// The model fields come off an unchecked JSON.parse cast, so a third-party
// rollout can carry a non-string `model`. It flows straight into calculateCost,
// which calls `.replace()` on it, so pick the first genuine string and never let
// a number/object/array through.
function firstModelString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === 'string' && value) return value
}
return undefined
}
function resolveModel(info: CodexEntry['payload'], sessionModel?: string): string {
return info?.model
?? info?.info?.model
?? info?.info?.model_name
?? sessionModel
?? 'gpt-5'
return firstModelString(info?.model, info?.info?.model, info?.info?.model_name, sessionModel) ?? 'gpt-5'
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
@ -640,13 +647,17 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
if (typeof rawSessionCwd === 'string' && rawSessionCwd) sessionCwd = rawSessionCwd
forkedFromId = entry.payload?.forked_from_id ?? ''
if (forkedFromId && entry.timestamp) {
forkCutoff = new Date(new Date(entry.timestamp).getTime() + 5000).toISOString()
// An unparseable timestamp (a garbage string, or a non-string from
// the unchecked JSON.parse cast) makes `new Date(NaN).toISOString()`
// throw RangeError, which would sink this whole session to zero.
const forkBaseMs = new Date(entry.timestamp).getTime()
if (Number.isFinite(forkBaseMs)) forkCutoff = new Date(forkBaseMs + 5000).toISOString()
}
sessionModel = entry.payload?.model ?? sessionModel
if (typeof entry.payload?.model === 'string') sessionModel = entry.payload.model
continue
}
if (entry.type === 'turn_context' && entry.payload?.model) {
if (entry.type === 'turn_context' && typeof entry.payload?.model === 'string') {
sessionModel = entry.payload.model
continue
}

View file

@ -404,6 +404,54 @@ describe('codex provider - session discovery', () => {
}
})
it('counts a forked rollout whose timestamp is unparseable instead of throwing it to zero', async () => {
// A forked session with a garbage (or non-string) timestamp used to make the
// fork-cutoff `new Date(NaN).toISOString()` throw RangeError, sinking the
// whole session's usage to zero. Same unchecked-JSON.parse class as cwd.
await writeSession(tmpDir, '2026-04-14', 'rollout-forked-badts.jsonl', [
JSON.stringify({
type: 'session_meta',
timestamp: 'not-a-real-timestamp',
payload: { cwd: '/Users/test/fork', session_id: 'sess-fork', model: 'gpt-5.5', originator: 't3code_desktop', forked_from_id: 'parent-1' },
}),
tokenCount({ timestamp: '2026-04-14T10:01:00Z', last: { input: 100, output: 50 }, total: { total: 150 } }),
])
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call)
expect(calls.length).toBeGreaterThan(0)
})
it('counts a rollout with a non-string model via the fallback instead of throwing', async () => {
// A non-string `model` used to ride sessionModel into calculateCost, which
// calls `.replace()` on it -> "model.replace is not a function" -> the whole
// session reads zero. It should fall back to a real model and be counted.
await writeSession(tmpDir, '2026-04-14', 'rollout-badmodel.jsonl', [
JSON.stringify({
type: 'session_meta',
timestamp: '2026-04-14T10:00:00Z',
payload: { cwd: '/Users/test/m', session_id: 'sess-badmodel', model: { name: 'gpt-5.5' }, originator: 't3code_desktop' },
}),
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
])
const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call)
expect(calls.length).toBeGreaterThan(0)
for (const call of calls) {
expect(typeof call.model).toBe('string')
expect(Number.isFinite(call.costUSD)).toBe(true)
}
})
it('accepts session_meta lines larger than 16 KB (Codex CLI 0.128+)', async () => {
// Codex CLI 0.128+ embeds the full base_instructions / system prompt in the
// first session_meta line, often pushing it past 20 KB. Regression guard