mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 07:25: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
|
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 {
|
function resolveModel(info: CodexEntry['payload'], sessionModel?: string): string {
|
||||||
return info?.model
|
return firstModelString(info?.model, info?.info?.model, info?.info?.model_name, sessionModel) ?? 'gpt-5'
|
||||||
?? info?.info?.model
|
|
||||||
?? info?.info?.model_name
|
|
||||||
?? sessionModel
|
|
||||||
?? 'gpt-5'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
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
|
if (typeof rawSessionCwd === 'string' && rawSessionCwd) sessionCwd = rawSessionCwd
|
||||||
forkedFromId = entry.payload?.forked_from_id ?? ''
|
forkedFromId = entry.payload?.forked_from_id ?? ''
|
||||||
if (forkedFromId && entry.timestamp) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === 'turn_context' && entry.payload?.model) {
|
if (entry.type === 'turn_context' && typeof entry.payload?.model === 'string') {
|
||||||
sessionModel = entry.payload.model
|
sessionModel = entry.payload.model
|
||||||
continue
|
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 () => {
|
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
|
// 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
|
// first session_meta line, often pushing it past 20 KB. Regression guard
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue