mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 15:05:16 +00:00
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:
parent
733003df35
commit
4ff3497eb8
2 changed files with 67 additions and 8 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue