fix(providers): guard two malformed-input crashes in the parse path

- vscode-cline-parser: entry.ts was truthy-checked but not validity-checked,
  so a garbage timestamp made new Date(ts).toISOString() throw RangeError and
  abort the whole session parse. Validate the date, fall back to empty.
- models: parseLiteLLMEntry read fields off its argument with no null/type
  guard, so a null value in the remote LiteLLM pricing JSON threw and aborted
  the entire live pricing load. Return null for a null/non-object entry.

Both mutation-checked: the tests raise RangeError / TypeError before the fix.
This commit is contained in:
ozymandiashh 2026-08-04 06:28:41 +03:00
parent 2b49608fd2
commit 48fd0daa0c
4 changed files with 52 additions and 2 deletions

View file

@ -164,7 +164,11 @@ function safePerTokenRate(n: number | undefined): number | null {
return n
}
function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
export function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
// The live LiteLLM map is remote JSON; a null (or non-object) value for a
// model would make the field reads below throw and abort the whole pricing
// load. Treat it as unparseable, like any other bad entry.
if (!entry || typeof entry !== 'object') return null
const inputCost = safePerTokenRate(entry.input_cost_per_token)
const outputCost = safePerTokenRate(entry.output_cost_per_token)
if (inputCost === null || outputCost === null) return null

View file

@ -192,7 +192,11 @@ export function createClineParser(source: SessionSource, seenKeys: Set<string>,
if (tokensIn === 0 && tokensOut === 0) continue
const timestamp = entry.ts ? new Date(entry.ts).toISOString() : ''
// entry.ts is truthy-checked but not validity-checked: a malformed
// ts (garbage string, out-of-range number) makes new Date().toISOString()
// throw RangeError, which would abort the whole session's parse. Guard it.
const tsDate = entry.ts ? new Date(entry.ts) : null
const timestamp = tsDate && !Number.isNaN(tsDate.getTime()) ? tsDate.toISOString() : ''
const costUSD = cost ?? calculateCost(model, tokensIn, tokensOut, cacheWrites, cacheReads, 0)
yield {

View file

@ -14,6 +14,7 @@ import {
setLocalModelSavings,
getLocalModelSavingsConfigHash,
getPriceOverridesConfigHash,
parseLiteLLMEntry,
} from '../src/models.js'
import { getDailyCacheConfigHash } from '../src/usage-aggregator.js'
@ -865,3 +866,18 @@ describe('findUnpricedModels', () => {
expect(unpriced.map(u => u.model)).toEqual(['zz-big', 'zz-small'])
})
})
describe('parseLiteLLMEntry hardening', () => {
it('returns null instead of throwing on a null or non-object entry', () => {
// The live LiteLLM map is remote JSON; a null value for a model used to
// throw on the field reads and abort the whole pricing load.
expect(parseLiteLLMEntry(null as unknown as Parameters<typeof parseLiteLLMEntry>[0])).toBeNull()
expect(parseLiteLLMEntry(undefined as unknown as Parameters<typeof parseLiteLLMEntry>[0])).toBeNull()
expect(parseLiteLLMEntry(42 as unknown as Parameters<typeof parseLiteLLMEntry>[0])).toBeNull()
})
it('still parses a valid entry', () => {
const costs = parseLiteLLMEntry({ input_cost_per_token: 0.000003, output_cost_per_token: 0.000015 } as Parameters<typeof parseLiteLLMEntry>[0])
expect(costs).not.toBeNull()
})
})

View file

@ -56,3 +56,29 @@ describe('VS Code Cline-family storage discovery', () => {
].sort())
})
})
import { createClineParser } from '../../src/providers/vscode-cline-parser.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
describe('VS Code Cline-family parse hardening', () => {
it('yields with an empty timestamp instead of throwing on a malformed ts', async () => {
// entry.ts is only truthy-checked; a garbage value made new Date(ts)
// .toISOString() throw RangeError and abort the whole session parse.
const taskDir = join(tmpDir, 'tasks', 'bad-ts')
await mkdir(taskDir, { recursive: true })
await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify([
{ type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 100, tokensOut: 50 }), ts: 'not-a-real-timestamp' },
]))
await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify([
{ role: 'user', content: [{ type: 'text', text: 'hi\n<environment_details>\n</environment_details>' }] },
]))
const source = { path: taskDir, project: 'p', provider: 'cline' }
const calls: ParsedProviderCall[] = []
for await (const call of createClineParser(source, new Set(), 'cline').parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]!.timestamp).toBe('')
expect(calls[0]!.inputTokens).toBe(100)
})
})