Skip to content

Commit 9a4f69d

Browse files
committed
fix(desktop): reject NaN/Infinity usage values to prevent counter corruption
A bad provider/IPC payload (NaN, Infinity, or negative) would silently poison weekUsage totals across sessions, since the previous guard only checked typeof === 'number'. Tighten validation with Number.isFinite + non-negative at both the parse-side (IPC payload → state) and the storage-load side (rehydrate from localStorage). On rejection: drop the bad value, console.warn, and surface a toast via the existing weekly-usage error UI surface (errors.weekUsageInvalid). Addresses PR #47 codex Major.
1 parent 52af5f2 commit 9a4f69d

4 files changed

Lines changed: 87 additions & 14 deletions

File tree

apps/desktop/src/renderer/src/store.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { initI18n } from '@open-codesign/i18n';
22
import type { LocalInputFile, OnboardingState, SelectedElement } from '@open-codesign/shared';
33
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
44
import type { GenerationStage } from './store';
5-
import { accumulateWeekUsage, isoWeekKey, useCodesignStore } from './store';
5+
import { accumulateWeekUsage, coerceUsageSnapshot, isoWeekKey, useCodesignStore } from './store';
66

77
const READY_CONFIG: OnboardingState = {
88
hasKey: true,
@@ -402,6 +402,41 @@ describe('accumulateWeekUsage', () => {
402402
});
403403
});
404404

405+
describe('coerceUsageSnapshot', () => {
406+
it('rejects NaN inputs and reports the field', () => {
407+
const { usage, rejected } = coerceUsageSnapshot({
408+
inputTokens: Number.NaN,
409+
outputTokens: 200,
410+
costUsd: 0.01,
411+
});
412+
expect(usage.inputTokens).toBe(0);
413+
expect(usage.outputTokens).toBe(200);
414+
expect(usage.costUsd).toBe(0.01);
415+
expect(rejected).toEqual(['inputTokens']);
416+
});
417+
418+
it('rejects Infinity inputs and reports the field', () => {
419+
const { usage, rejected } = coerceUsageSnapshot({
420+
inputTokens: 100,
421+
outputTokens: Number.POSITIVE_INFINITY,
422+
costUsd: Number.NEGATIVE_INFINITY,
423+
});
424+
expect(usage.outputTokens).toBe(0);
425+
expect(usage.costUsd).toBe(0);
426+
expect(rejected).toEqual(['outputTokens', 'costUsd']);
427+
});
428+
429+
it('accepts finite zero without rejecting', () => {
430+
const { usage, rejected } = coerceUsageSnapshot({
431+
inputTokens: 0,
432+
outputTokens: 0,
433+
costUsd: 0,
434+
});
435+
expect(usage).toEqual({ inputTokens: 0, outputTokens: 0, costUsd: 0 });
436+
expect(rejected).toEqual([]);
437+
});
438+
});
439+
405440
// Simulate the escape handler logic from App.tsx to verify priority:
406441
// commandPaletteOpen → close palette (view unchanged)
407442
// palette closed + view=settings → go to workspace

apps/desktop/src/renderer/src/store.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,32 @@ export function isoWeekKey(date: Date): string {
213213
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
214214
}
215215

216+
function isFiniteUsageNumber(v: unknown): v is number {
217+
return typeof v === 'number' && Number.isFinite(v) && v >= 0;
218+
}
219+
220+
export function coerceUsageSnapshot(result: {
221+
inputTokens?: unknown;
222+
outputTokens?: unknown;
223+
costUsd?: unknown;
224+
}): { usage: UsageSnapshot; rejected: string[] } {
225+
const rejected: string[] = [];
226+
const pick = (label: string, v: unknown): number => {
227+
if (v === undefined) return 0;
228+
if (isFiniteUsageNumber(v)) return v;
229+
rejected.push(label);
230+
return 0;
231+
};
232+
return {
233+
usage: {
234+
inputTokens: pick('inputTokens', result.inputTokens),
235+
outputTokens: pick('outputTokens', result.outputTokens),
236+
costUsd: pick('costUsd', result.costUsd),
237+
},
238+
rejected,
239+
};
240+
}
241+
216242
function readStoredWeekUsage(now: Date): WeekUsage {
217243
const fresh: WeekUsage = {
218244
isoWeek: isoWeekKey(now),
@@ -228,9 +254,9 @@ function readStoredWeekUsage(now: Date): WeekUsage {
228254
const parsed = JSON.parse(raw) as Partial<WeekUsage>;
229255
if (
230256
typeof parsed.isoWeek !== 'string' ||
231-
typeof parsed.inputTokens !== 'number' ||
232-
typeof parsed.outputTokens !== 'number' ||
233-
typeof parsed.costUsd !== 'number'
257+
!isFiniteUsageNumber(parsed.inputTokens) ||
258+
!isFiniteUsageNumber(parsed.outputTokens) ||
259+
!isFiniteUsageNumber(parsed.costUsd)
234260
) {
235261
warnReason = 'weekly usage entry has unexpected shape';
236262
} else if (parsed.isoWeek === fresh.isoWeek) {
@@ -375,11 +401,7 @@ function applyGenerateSuccess(
375401
},
376402
): void {
377403
const firstArtifact = result.artifacts[0];
378-
const usage: UsageSnapshot = {
379-
inputTokens: typeof result.inputTokens === 'number' ? result.inputTokens : 0,
380-
outputTokens: typeof result.outputTokens === 'number' ? result.outputTokens : 0,
381-
costUsd: typeof result.costUsd === 'number' ? result.costUsd : 0,
382-
};
404+
const { usage, rejected: rejectedUsageFields } = coerceUsageSnapshot(result);
383405
let persistError: string | null = null;
384406
let didApply = false;
385407
finishIfCurrent(set, generationId, (state) => {
@@ -400,6 +422,15 @@ function applyGenerateSuccess(
400422
weekUsage: nextWeek,
401423
};
402424
});
425+
if (didApply && rejectedUsageFields.length > 0) {
426+
const detail = rejectedUsageFields.join(', ');
427+
console.warn('[open-codesign] dropped non-finite usage values from provider:', detail);
428+
get().pushToast({
429+
variant: 'error',
430+
title: tr('errors.weekUsageInvalid'),
431+
description: detail,
432+
});
433+
}
403434
if (didApply && persistError) {
404435
console.warn('[open-codesign] failed to persist weekly usage:', persistError);
405436
get().pushToast({
@@ -758,11 +789,7 @@ export const useCodesignStore = create<CodesignState>((set, get) => ({
758789
attachments,
759790
});
760791
const firstArtifact = result.artifacts[0];
761-
const usage: UsageSnapshot = {
762-
inputTokens: typeof result.inputTokens === 'number' ? result.inputTokens : 0,
763-
outputTokens: typeof result.outputTokens === 'number' ? result.outputTokens : 0,
764-
costUsd: typeof result.costUsd === 'number' ? result.costUsd : 0,
765-
};
792+
const { usage, rejected: rejectedUsageFields } = coerceUsageSnapshot(result);
766793
let persistError: string | null = null;
767794
set((s) => {
768795
const nextWeek = accumulateWeekUsage(s.weekUsage, usage, new Date());
@@ -779,6 +806,15 @@ export const useCodesignStore = create<CodesignState>((set, get) => ({
779806
weekUsage: nextWeek,
780807
};
781808
});
809+
if (rejectedUsageFields.length > 0) {
810+
const detail = rejectedUsageFields.join(', ');
811+
console.warn('[open-codesign] dropped non-finite usage values from provider:', detail);
812+
get().pushToast({
813+
variant: 'error',
814+
title: tr('errors.weekUsageInvalid'),
815+
description: detail,
816+
});
817+
}
782818
if (persistError) {
783819
console.warn('[open-codesign] failed to persist weekly usage:', persistError);
784820
get().pushToast({

packages/i18n/src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@
395395
"localePersistFailed": "Failed to save language preference",
396396
"projectStorageFailed": "Couldn't read or write projects on this device. Your changes may not persist.",
397397
"storageFailed": "Failed to save to local storage",
398+
"weekUsageInvalid": "Provider returned invalid usage values; ignored to keep weekly totals accurate.",
398399
"weekUsageReadFailed": "Couldn't read this week's usage from local storage. Showing fresh totals."
399400
},
400401
"demos": {

packages/i18n/src/locales/zh-CN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@
394394
"localePersistFailed": "无法保存语言偏好",
395395
"projectStorageFailed": "本机的项目数据读写失败,改动可能未保存。",
396396
"storageFailed": "本地存储写入失败",
397+
"weekUsageInvalid": "模型提供方返回了非法的用量值,已忽略以保证周用量统计准确。",
397398
"weekUsageReadFailed": "无法从本地存储读取本周用量,已重置为新一轮统计。"
398399
},
399400
"demos": {

0 commit comments

Comments
 (0)