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.<provider>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 <cmeia.ai02@cmeia.co.kr>
This commit is contained in:
scokeepa 2026-07-24 16:28:52 +09:00 committed by GitHub
parent dc18ba9cda
commit 6997bc40ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 126 additions and 6 deletions

View File

@ -45,7 +45,14 @@ export const createClaudeUsageSlice: StateCreator<AppState, [], [], ClaudeUsageS
try {
const nextScanState = (await window.api.claudeUsage.setEnabled({
enabled
})) as ClaudeUsageScanState
})) as ClaudeUsageScanState | undefined
// Why: the web client (paired runtime) does not bridge the desktop-only
// usage IPC; its preload fallback resolves this call to `undefined`. Bail
// so the toggle no-ops instead of seeding an empty scan state and then
// crashing on the follow-up fetch.
if (!nextScanState) {
return
}
set({
// Why: every enable should look like a fresh scan cycle in the UI.
// Reusing the last completed timestamp makes repeated toggles skip the
@ -84,7 +91,16 @@ export const createClaudeUsageSlice: StateCreator<AppState, [], [], ClaudeUsageS
fetchClaudeUsage: async (opts) => {
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 &&

View File

@ -45,7 +45,14 @@ export const createCodexUsageSlice: StateCreator<AppState, [], [], CodexUsageSli
try {
const nextScanState = (await window.api.codexUsage.setEnabled({
enabled
})) as CodexUsageScanState
})) as CodexUsageScanState | undefined
// Why: the web client (paired runtime) does not bridge the desktop-only
// usage IPC; its preload fallback resolves this call to `undefined`. Bail
// so the toggle no-ops instead of seeding an empty scan state and then
// crashing on the follow-up fetch.
if (!nextScanState) {
return
}
set({
codexUsageScanState: enabled
? {
@ -81,7 +88,16 @@ export const createCodexUsageSlice: StateCreator<AppState, [], [], CodexUsageSli
fetchCodexUsage: async (opts) => {
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 &&

View File

@ -45,7 +45,14 @@ export const createOpenCodeUsageSlice: StateCreator<AppState, [], [], OpenCodeUs
try {
const nextScanState = (await window.api.openCodeUsage.setEnabled({
enabled
})) as OpenCodeUsageScanState
})) as OpenCodeUsageScanState | undefined
// Why: the web client (paired runtime) does not bridge the desktop-only
// usage IPC; its preload fallback resolves this call to `undefined`. Bail
// so the toggle no-ops instead of seeding an empty scan state and then
// crashing on the follow-up fetch.
if (!nextScanState) {
return
}
set({
openCodeUsageScanState: enabled
? {
@ -81,7 +88,16 @@ export const createOpenCodeUsageSlice: StateCreator<AppState, [], [], OpenCodeUs
fetchOpenCodeUsage: async (opts) => {
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 &&

View File

@ -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.<provider>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<AppState>()((...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<AppState>()((...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<AppState>()((...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()
})
})