From 6997bc40ab58827682b9262cccb5d6ef775bb841 Mon Sep 17 00:00:00 2001 From: scokeepa Date: Fri, 24 Jul 2026 16:28:52 +0900 Subject: [PATCH] fix(usage): guard web client against undefined usage scan state (#10073) The claude/codex/openCode usage store slices read `scanState.enabled` directly off `window.api.Usage.getScanState()`. In the web client that usage IPC is not bridged, so the preload fallback proxy resolves those calls to `undefined`, and enabling usage tracking from Settings -> Stats & Usage throws `TypeError: Cannot read properties of undefined (reading 'enabled')` (reproduced live against `orca serve` v1.4.150; present on main too). Guard the getScanState()/setEnabled() seams in all three slices so an absent scan state degrades to a graceful no-op instead of crashing. Desktop behavior is unchanged (a real ScanState is always truthy). Adds a regression test that stubs the web-client fallback (every call -> undefined) and asserts fetch*/enable* no-op without throwing for all three providers. Co-authored-by: ECO2G Migration --- src/renderer/src/store/slices/claude-usage.ts | 20 +++++- src/renderer/src/store/slices/codex-usage.ts | 20 +++++- .../src/store/slices/opencode-usage.ts | 20 +++++- .../slices/usage-web-client-fallback.test.ts | 72 +++++++++++++++++++ 4 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/store/slices/usage-web-client-fallback.test.ts diff --git a/src/renderer/src/store/slices/claude-usage.ts b/src/renderer/src/store/slices/claude-usage.ts index bb2326a0f..b9e6d0c74 100644 --- a/src/renderer/src/store/slices/claude-usage.ts +++ b/src/renderer/src/store/slices/claude-usage.ts @@ -45,7 +45,14 @@ export const createClaudeUsageSlice: StateCreator { try { - const scanState = (await window.api.claudeUsage.getScanState()) as ClaudeUsageScanState + const scanState = (await window.api.claudeUsage.getScanState()) as + | ClaudeUsageScanState + | undefined + // Why: in the web client the usage IPC is unavailable and the preload + // fallback resolves to `undefined`; reading `scanState.enabled` below would + // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an + // absent scan state as "usage unavailable" and stop. + if (!scanState) { + return + } const currentScanState = get().claudeUsageScanState const shouldPreserveLoadingState = opts?.forceRefresh === true && diff --git a/src/renderer/src/store/slices/codex-usage.ts b/src/renderer/src/store/slices/codex-usage.ts index bf4bf442f..fa90bc0ea 100644 --- a/src/renderer/src/store/slices/codex-usage.ts +++ b/src/renderer/src/store/slices/codex-usage.ts @@ -45,7 +45,14 @@ export const createCodexUsageSlice: StateCreator { try { - const scanState = (await window.api.codexUsage.getScanState()) as CodexUsageScanState + const scanState = (await window.api.codexUsage.getScanState()) as + | CodexUsageScanState + | undefined + // Why: in the web client the usage IPC is unavailable and the preload + // fallback resolves to `undefined`; reading `scanState.enabled` below would + // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an + // absent scan state as "usage unavailable" and stop. + if (!scanState) { + return + } const currentScanState = get().codexUsageScanState const shouldPreserveLoadingState = opts?.forceRefresh === true && diff --git a/src/renderer/src/store/slices/opencode-usage.ts b/src/renderer/src/store/slices/opencode-usage.ts index 2c931fded..87c28e782 100644 --- a/src/renderer/src/store/slices/opencode-usage.ts +++ b/src/renderer/src/store/slices/opencode-usage.ts @@ -45,7 +45,14 @@ export const createOpenCodeUsageSlice: StateCreator { try { - const scanState = (await window.api.openCodeUsage.getScanState()) as OpenCodeUsageScanState + const scanState = (await window.api.openCodeUsage.getScanState()) as + | OpenCodeUsageScanState + | undefined + // Why: in the web client the usage IPC is unavailable and the preload + // fallback resolves to `undefined`; reading `scanState.enabled` below would + // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an + // absent scan state as "usage unavailable" and stop. + if (!scanState) { + return + } const currentScanState = get().openCodeUsageScanState const shouldPreserveLoadingState = opts?.forceRefresh === true && diff --git a/src/renderer/src/store/slices/usage-web-client-fallback.test.ts b/src/renderer/src/store/slices/usage-web-client-fallback.test.ts new file mode 100644 index 000000000..6dd6f6470 --- /dev/null +++ b/src/renderer/src/store/slices/usage-web-client-fallback.test.ts @@ -0,0 +1,72 @@ +import { create } from 'zustand' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import { createClaudeUsageSlice } from './claude-usage' +import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' + +// Regression: in the web client (paired `orca serve` runtime) the desktop-only +// usage IPC is not bridged, so the preload fallback proxy resolves every +// `window.api.Usage.*` call to `undefined`. Before the guards, the +// slices read `scanState.enabled` off that `undefined` and threw +// `TypeError: Cannot read properties of undefined (reading 'enabled')` when a +// user opened Settings -> Stats & Usage and pressed "enable" for an agent. +// +// These tests stub the web-client fallback (every call -> undefined) and assert +// the slices degrade to a no-op instead of throwing. + +function stubWebClientFallback(): void { + // Mirrors web-preload-api's createFallbackProxy: any method resolves to undefined. + const undefinedAsync = vi.fn(() => Promise.resolve(undefined)) + const provider = { + getScanState: undefinedAsync, + setEnabled: undefinedAsync, + getSnapshot: undefinedAsync, + refresh: undefinedAsync, + getSummary: undefinedAsync, + getDaily: undefinedAsync, + getBreakdown: undefinedAsync, + getRecentSessions: undefinedAsync + } + vi.stubGlobal('window', { + api: { + claudeUsage: provider, + codexUsage: provider, + openCodeUsage: provider + } + }) +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('usage slices in the web client (preload fallback -> undefined)', () => { + it('claude: fetch and enable no-op without throwing', async () => { + stubWebClientFallback() + const store = create()((...args) => createClaudeUsageSlice(...args) as AppState) + await expect(store.getState().fetchClaudeUsage()).resolves.toBeUndefined() + await expect(store.getState().enableClaudeUsage()).resolves.toBeUndefined() + expect(store.getState().claudeUsageScanState).toBeNull() + expect(store.getState().claudeUsageSummary).toBeNull() + }) + + it('codex: fetch and enable no-op without throwing', async () => { + stubWebClientFallback() + const store = create()((...args) => createCodexUsageSlice(...args) as AppState) + await expect(store.getState().fetchCodexUsage()).resolves.toBeUndefined() + await expect(store.getState().enableCodexUsage()).resolves.toBeUndefined() + expect(store.getState().codexUsageScanState).toBeNull() + expect(store.getState().codexUsageSummary).toBeNull() + }) + + it('opencode: fetch and enable no-op without throwing', async () => { + stubWebClientFallback() + const store = create()((...args) => createOpenCodeUsageSlice(...args) as AppState) + await expect(store.getState().fetchOpenCodeUsage()).resolves.toBeUndefined() + await expect(store.getState().enableOpenCodeUsage()).resolves.toBeUndefined() + expect(store.getState().openCodeUsageScanState).toBeNull() + expect(store.getState().openCodeUsageSummary).toBeNull() + }) +})