fix: pricing accuracy, stream leak, CSV injection hardening

- Remove bidirectional fuzzy match in getModelCosts that could return
  wrong pricing when a short canonical name prefix-matched a longer key
- Use explicit undefined check in parseLiteLLMEntry so free models with
  zero cost are not silently dropped from the LiteLLM pricing database
- Destroy read stream in finally block of readSessionLines to prevent
  file descriptor leaks when the generator is abandoned early
- Extend CSV injection escaping to cover tab and carriage-return prefixes
- Add optional chaining fallback for empty periods in exportCsv/exportJson
- Add regression tests for all fixes (models, export, fs-utils)
This commit is contained in:
iamtoruk 2026-04-20 14:49:32 -07:00 committed by AgentSeal
parent b61e7cd32a
commit a4d261a536
6 changed files with 117 additions and 6 deletions

View file

@ -6,7 +6,7 @@ import { getCurrency, convertCost } from './currency.js'
import { dateKey } from './day-aggregator.js'
function escCsv(s: string): string {
const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s
const sanitized = /^[\t\r=+\-@]/.test(s) ? `'${s}` : s
if (sanitized.includes(',') || sanitized.includes('"') || sanitized.includes('\n')) {
return `"${sanitized.replace(/"/g, '""')}"`
}
@ -283,7 +283,7 @@ async function clearCodeburnExportFolder(path: string): Promise<void> {
/// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally).
export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise<string> {
const thirtyDays = periods.find(p => p.label === '30 Days')
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? []
let folder = resolve(outputPath)
if (folder.toLowerCase().endsWith('.csv')) {
@ -325,7 +325,7 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr
export async function exportJson(periods: PeriodExport[], outputPath: string): Promise<string> {
const thirtyDays = periods.find(p => p.label === '30 Days')
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? []
const { code, rate, symbol } = getCurrency()
const data = {

View file

@ -89,5 +89,7 @@ export async function* readSessionLines(filePath: string): AsyncGenerator<string
for await (const line of rl) yield line
} catch (err) {
warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
} finally {
stream.destroy()
}
}

View file

@ -62,7 +62,7 @@ function getCachePath(): string {
}
function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
if (!entry.input_cost_per_token || !entry.output_cost_per_token) return null
if (entry.input_cost_per_token === undefined || entry.output_cost_per_token === undefined) return null
return {
inputCostPerToken: entry.input_cost_per_token,
outputCostPerToken: entry.output_cost_per_token,
@ -140,7 +140,7 @@ export function getModelCosts(model: string): ModelCosts | null {
}
for (const [key, costs] of pricingCache ?? new Map()) {
if (canonical.startsWith(key) || key.startsWith(canonical)) return costs
if (canonical.startsWith(key)) return costs
}
for (const [key, costs] of Object.entries(FALLBACK_PRICING)) {

View file

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, readFile, rm } from 'fs/promises'
import { mkdtemp, readFile, readdir, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
@ -134,4 +134,26 @@ describe('exportCsv', () => {
expect(content).toContain("'+danger-model")
expect(content).toContain("'@malicious")
})
it('escapes tab and carriage-return prefixes in CSV cells', async () => {
const periods: PeriodExport[] = [
{
label: '30 Days',
projects: [makeProject('\tcmd'), makeProject('\rcmd')],
},
]
const outputPath = join(tmpDir, 'tab-cr.csv')
const folder = await exportCsv(periods, outputPath)
const projects = await readFile(join(folder, 'projects.csv'), 'utf-8')
expect(projects).toContain("'\tcmd")
expect(projects).toContain("'\rcmd")
})
it('does not crash when periods array is empty', async () => {
const outputPath = join(tmpDir, 'empty.csv')
const folder = await exportCsv([], outputPath)
const entries = await readdir(folder)
expect(entries.length).toBeGreaterThanOrEqual(0)
})
})

View file

@ -7,6 +7,7 @@ import {
MAX_SESSION_FILE_BYTES,
STREAM_THRESHOLD_BYTES,
readSessionFile,
readSessionLines,
} from '../src/fs-utils.js'
describe('readSessionFile', () => {
@ -61,3 +62,37 @@ describe('readSessionFile', () => {
expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull()
})
})
describe('readSessionLines', () => {
const tmpDirs: string[] = []
afterEach(async () => {
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function tmpPath(content: string): Promise<string> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-lines-'))
tmpDirs.push(base)
const p = join(base, 'session.jsonl')
await writeFile(p, content)
return p
}
it('yields all lines from a file', async () => {
const p = await tmpPath('line1\nline2\nline3\n')
const lines: string[] = []
for await (const line of readSessionLines(p)) lines.push(line)
expect(lines).toEqual(['line1', 'line2', 'line3'])
})
it('does not leak file descriptors when generator is abandoned early', async () => {
const content = Array.from({ length: 1000 }, (_, i) => `line-${i}`).join('\n')
const p = await tmpPath(content)
const gen = readSessionLines(p)
await gen.next()
await gen.return(undefined)
})
})

52
tests/models.test.ts Normal file
View file

@ -0,0 +1,52 @@
import { describe, it, expect, beforeAll } from 'vitest'
import { getModelCosts, getShortModelName, loadPricing } from '../src/models.js'
beforeAll(async () => {
await loadPricing()
})
describe('getModelCosts', () => {
it('does not match short canonical against longer pricing key', () => {
const costs = getModelCosts('gpt-4')
if (costs) {
expect(costs.inputCostPerToken).not.toBe(2.5e-6)
}
})
it('returns correct pricing for gpt-4o vs gpt-4o-mini', () => {
const mini = getModelCosts('gpt-4o-mini')
const full = getModelCosts('gpt-4o')
expect(mini).not.toBeNull()
expect(full).not.toBeNull()
expect(mini!.inputCostPerToken).toBeLessThan(full!.inputCostPerToken)
})
it('returns fallback pricing for known Claude models', () => {
const costs = getModelCosts('claude-opus-4-6-20260205')
expect(costs).not.toBeNull()
expect(costs!.inputCostPerToken).toBe(5e-6)
})
})
describe('getShortModelName', () => {
it('maps gpt-4o-mini correctly (not gpt-4o)', () => {
expect(getShortModelName('gpt-4o-mini-2024-07-18')).toBe('GPT-4o Mini')
})
it('maps gpt-4o correctly', () => {
expect(getShortModelName('gpt-4o-2024-08-06')).toBe('GPT-4o')
})
it('maps gpt-4.1-mini correctly (not gpt-4.1)', () => {
expect(getShortModelName('gpt-4.1-mini-2025-04-14')).toBe('GPT-4.1 Mini')
})
it('maps gpt-5.4-mini correctly (not gpt-5.4)', () => {
expect(getShortModelName('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
})
it('maps claude-opus-4-6 with date suffix', () => {
expect(getShortModelName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
})
})