From 48fd0daa0cafcda399fb87d69d478d315f71a8d5 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:28:41 +0300 Subject: [PATCH] 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. --- src/models.ts | 6 ++++- src/providers/vscode-cline-parser.ts | 6 ++++- tests/models.test.ts | 16 +++++++++++++ tests/providers/vscode-cline-parser.test.ts | 26 +++++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/models.ts b/src/models.ts index ccc2d0cf..d54ce9a0 100644 --- a/src/models.ts +++ b/src/models.ts @@ -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 diff --git a/src/providers/vscode-cline-parser.ts b/src/providers/vscode-cline-parser.ts index 2535a22c..655b74fa 100644 --- a/src/providers/vscode-cline-parser.ts +++ b/src/providers/vscode-cline-parser.ts @@ -192,7 +192,11 @@ export function createClineParser(source: SessionSource, seenKeys: Set, 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 { diff --git a/tests/models.test.ts b/tests/models.test.ts index adbf9fa6..75ffbea8 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -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[0])).toBeNull() + expect(parseLiteLLMEntry(undefined as unknown as Parameters[0])).toBeNull() + expect(parseLiteLLMEntry(42 as unknown as Parameters[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[0]) + expect(costs).not.toBeNull() + }) +}) diff --git a/tests/providers/vscode-cline-parser.test.ts b/tests/providers/vscode-cline-parser.test.ts index b250b0c1..83520462 100644 --- a/tests/providers/vscode-cline-parser.test.ts +++ b/tests/providers/vscode-cline-parser.test.ts @@ -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\n' }] }, + ])) + + 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) + }) +})