diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 3361e415030..1a07a0d6ecc 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -13,7 +13,6 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native' import { loadHosts } from '../../../src/transport/host-store' import { useHostClient } from '../../../src/transport/client-context' -import type { RpcSuccess } from '../../../src/transport/types' import { colors, spacing } from '../../../src/theme/mobile-theme' import { styles } from './accounts-screen-styles' import { useNow } from '../../../src/hooks/use-now' @@ -21,6 +20,7 @@ import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons' import { type AccountsSnapshot, type ProviderKey, + decodeAccountsSnapshot, getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, @@ -28,6 +28,12 @@ import { hasActiveProviderUsage, UsageBar } from '../../../src/components/AccountUsage' +import { + getActiveCodexAccountIdForRateLimitTarget, + getCodexResetCreditSummary +} from '../../../src/components/codex-reset-credit' +import { CodexResetCreditAction } from '../../../src/components/CodexResetCreditAction' +import { useCodexResetCreditAction } from '../../../src/components/use-codex-reset-credit-action' export default function AccountsScreen() { const router = useRouter() @@ -43,6 +49,31 @@ export default function AccountsScreen() { const [busyAccountId, setBusyAccountId] = useState(null) const [clockEnabled, setClockEnabled] = useState(false) + const acceptSnapshot = useCallback((nextSnapshot: AccountsSnapshot) => { + setSnapshot(nextSnapshot) + setError(null) + }, []) + const rejectInvalidSnapshot = useCallback(() => { + // Why: a stale snapshot can expose a finite reset action for the wrong + // account; fail closed if a host sends a shape this mobile cannot prove. + setSnapshot(null) + setError('Invalid accounts snapshot from host') + }, []) + const { + supported: codexResetSupported, + resetting: resettingCodex, + resetScope, + scopeLabel: resetScopeLabel, + confirmReset: confirmCodexReset + } = useCodexResetCreditAction({ + client, + connected: connState === 'connected', + hostId, + snapshot, + accountMutationBusy: busyAccountId !== null, + onSnapshot: acceptSnapshot + }) + useFocusEffect( useCallback(() => { setClockEnabled(true) @@ -85,14 +116,17 @@ export default function AccountsScreen() { if (!payload || typeof payload !== 'object') { return } - const evt = payload as { type?: string; snapshot?: AccountsSnapshot } - if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) { - setSnapshot(evt.snapshot) - setError(null) + const evt = payload as { type?: string; snapshot?: unknown } + if (evt.type === 'ready' || evt.type === 'snapshot') { + try { + acceptSnapshot(decodeAccountsSnapshot(evt.snapshot)) + } catch { + rejectInvalidSnapshot() + } } }) return unsubscribe - }, [client, connState]) + }, [acceptSnapshot, client, connState, rejectInvalidSnapshot]) const refresh = useCallback(async () => { if (!client) { @@ -102,27 +136,43 @@ export default function AccountsScreen() { try { const res = await client.sendRequest('accounts.list') if (res.ok) { - setSnapshot((res as RpcSuccess).result as AccountsSnapshot) - setError(null) + acceptSnapshot(decodeAccountsSnapshot(res.result)) } else { setError(res.error.message) } } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + if (e instanceof Error && e.message === 'Invalid accounts snapshot from host') { + rejectInvalidSnapshot() + } else { + setError(e instanceof Error ? e.message : String(e)) + } } finally { setRefreshing(false) } - }, [client]) + }, [acceptSnapshot, client, rejectInvalidSnapshot]) const selectAccount = useCallback( async (provider: ProviderKey, accountId: string | null) => { if (!client) { return } + const codexTarget = provider === 'codex' ? snapshot?.rateLimits.codexTarget : null + if (provider === 'codex' && !codexTarget) { + return + } setBusyAccountId(accountId ?? `${provider}:default`) - const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex' + const method = + provider === 'claude' + ? 'accounts.selectClaude' + : codexTarget?.runtime === 'wsl' + ? 'accounts.selectCodexForTarget' + : 'accounts.selectCodex' try { - const res = await client.sendRequest(method, { accountId }) + // Why: old hosts silently strip unknown target fields. Use the distinct + // targeted RPC for WSL so version skew fails before mutating host state. + const params = + codexTarget?.runtime === 'wsl' ? { accountId, target: codexTarget } : { accountId } + const res = await client.sendRequest(method, params) if (!res.ok) { Alert.alert('Could not switch account', res.error.message) } else { @@ -137,7 +187,7 @@ export default function AccountsScreen() { setBusyAccountId(null) } }, - [client, refresh] + [client, refresh, snapshot] ) const renderProviderSection = (provider: ProviderKey, title: string) => { @@ -145,9 +195,14 @@ export default function AccountsScreen() { return null } const state = provider === 'claude' ? snapshot.claude : snapshot.codex + const activeAccountId = + provider === 'codex' && snapshot.codex.activeAccountIdsByRuntime + ? getActiveCodexAccountIdForRateLimitTarget(snapshot) + : state.activeAccountId const activeUsage = getActiveProviderRateLimits(snapshot, provider) const activeSessionBar = getUsageBarState(activeUsage, 'session') const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly') + const resetCredit = provider === 'codex' ? getCodexResetCreditSummary(activeUsage, now) : null const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon return ( @@ -160,7 +215,7 @@ export default function AccountsScreen() { [styles.row, pressed && styles.rowPressed]} onPress={() => selectAccount(provider, null)} - disabled={busyAccountId !== null || connState !== 'connected'} + disabled={busyAccountId !== null || resettingCodex || connState !== 'connected'} > System default @@ -168,7 +223,7 @@ export default function AccountsScreen() { {/* Why: when system default is the active selection, activeUsage holds the system-default login's rate limits — surface them here so non-managed users still see their usage. */} - {state.activeAccountId === null && hasActiveProviderUsage(activeUsage) ? ( + {activeAccountId === null && hasActiveProviderUsage(activeUsage) ? ( - {state.activeAccountId === null ? ( + {activeAccountId === null ? ( ) : busyAccountId === `${provider}:default` ? ( @@ -197,7 +252,7 @@ export default function AccountsScreen() { {state.accounts.map((account) => { - const isActive = state.activeAccountId === account.id + const isActive = activeAccountId === account.id const inactiveEntry = !isActive ? getInactiveProviderUsage(snapshot, provider, account.id) : null @@ -213,7 +268,12 @@ export default function AccountsScreen() { [styles.row, pressed && styles.rowPressed]} onPress={() => selectAccount(provider, account.id)} - disabled={busyAccountId !== null || connState !== 'connected' || isActive} + disabled={ + busyAccountId !== null || + resettingCodex || + connState !== 'connected' || + isActive + } > @@ -252,6 +312,15 @@ export default function AccountsScreen() { ) })} + {resetCredit && codexResetSupported && resetScope && connState === 'connected' ? ( + + ) : null} ) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index b7132849845..f486fe99c78 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -17,6 +17,7 @@ import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons' import { type AccountsSnapshot, type ProviderKey, + decodeAccountsSnapshot, getActiveProviderRateLimits, getUsageBarState, hasActiveProviderUsage, @@ -231,7 +232,7 @@ function fetchAccountsSnapshot( return } if (response.ok) { - const snapshot = response.result as AccountsSnapshot + const snapshot = decodeAccountsSnapshot(response.result) setSnapshots((prev) => ({ ...prev, [hostId]: snapshot })) } }) @@ -503,9 +504,15 @@ export default function HomeScreen() { if (!payload || typeof payload !== 'object') { return } - const evt = payload as { type?: string; snapshot?: AccountsSnapshot } - if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) { - setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: evt.snapshot! })) + const evt = payload as { type?: string; snapshot?: unknown } + if (evt.type === 'ready' || evt.type === 'snapshot') { + try { + const snapshot = decodeAccountsSnapshot(evt.snapshot) + setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) + } catch { + // Keep the last proven snapshot; malformed remote data must + // not enter render state or crash the home host cards. + } } }) } diff --git a/mobile/scripts/mock-server-account-rpc.ts b/mobile/scripts/mock-server-account-rpc.ts new file mode 100644 index 00000000000..87fb94e8be6 --- /dev/null +++ b/mobile/scripts/mock-server-account-rpc.ts @@ -0,0 +1,90 @@ +import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers' +import { + consumeMockCodexResetCredit, + createMockAccountsSnapshot, + selectMockClaudeAccount, + selectMockCodexAccount +} from './mock-server-account-state' + +type Respond = (response: RpcResponse) => void +type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse +type ErrorResponse = (id: string, code: string, message: string) => RpcResponse + +const accountSubscribers = new Map() + +function notifyAccountSubscribers(success: Success): void { + for (const { requestId, respond } of accountSubscribers.values()) { + respond(success(requestId, { type: 'snapshot', snapshot: createMockAccountsSnapshot() }, true)) + } +} + +export function handleMockAccountRequest( + request: RpcRequest, + respond: Respond, + success: Success, + error: ErrorResponse +): boolean { + try { + switch (request.method) { + case 'accounts.list': + respond(success(request.id, createMockAccountsSnapshot())) + return true + case 'accounts.selectClaude': + selectMockClaudeAccount(request.params?.accountId) + respond(success(request.id, createMockAccountsSnapshot().claude)) + notifyAccountSubscribers(success) + return true + case 'accounts.selectCodex': + case 'accounts.selectCodexForTarget': + selectMockCodexAccount(request.params?.accountId) + respond(success(request.id, createMockAccountsSnapshot().codex)) + notifyAccountSubscribers(success) + return true + case 'accounts.consumeCodexResetCredit': { + const result = consumeMockCodexResetCredit( + request.params?.idempotencyKey, + request.params?.expectedScope + ) + respond( + success(request.id, { + ...result, + snapshot: createMockAccountsSnapshot() + }) + ) + notifyAccountSubscribers(success) + return true + } + case 'accounts.subscribe': + accountSubscribers.set(`accounts-${request.id}`, { requestId: request.id, respond }) + respond( + success( + request.id, + { + type: 'ready', + subscriptionId: `accounts-${request.id}`, + snapshot: createMockAccountsSnapshot() + }, + true + ) + ) + return true + case 'accounts.unsubscribe': + if (typeof request.params?.subscriptionId === 'string') { + accountSubscribers.delete(request.params.subscriptionId) + } + respond(success(request.id, { unsubscribed: true })) + return true + default: + return false + } + } catch (caught) { + respond( + error( + request.id, + 'invalid_params', + caught instanceof Error ? caught.message : 'Invalid account request' + ) + ) + return true + } +} diff --git a/mobile/scripts/mock-server-account-state.ts b/mobile/scripts/mock-server-account-state.ts new file mode 100644 index 00000000000..0ccb48f9ecc --- /dev/null +++ b/mobile/scripts/mock-server-account-state.ts @@ -0,0 +1,243 @@ +import { + buildCodexResetCreditExpectedScope, + type CodexResetCreditExpectedScope +} from '../../src/shared/codex-reset-credit-scope' + +type MockCodexUsage = { + availableResetCredits: number + sessionUsedPercent: number + updatedAt: number + nextExpiresAt: number +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const CODEX_ACCOUNTS = [ + { + id: 'codex-personal', + email: 'dev@example.com', + workspaceLabel: 'Personal', + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'codex-team', + email: 'dev@example.com', + workspaceLabel: 'Example Team', + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } +] as const + +let fixtureStartedAt = Date.now() +let activeClaudeAccountId: string | null = 'claude-team' +let activeCodexAccountId: string | null = 'codex-personal' +let codexUsageByAccount = new Map() +let resetOperations = new Map() +let resetOfferOwners = new Map() + +function createInitialCodexUsage(accountOffset: number): MockCodexUsage { + return { + availableResetCredits: 1, + sessionUsedPercent: 100, + updatedAt: fixtureStartedAt + accountOffset, + nextExpiresAt: fixtureStartedAt + (5 + accountOffset) * 24 * 60 * 60 * 1000 + } +} + +export function resetMockAccountState(now = Date.now()): void { + fixtureStartedAt = now + activeClaudeAccountId = 'claude-team' + activeCodexAccountId = 'codex-personal' + codexUsageByAccount = new Map([ + ['codex-personal', createInitialCodexUsage(0)], + ['codex-team', createInitialCodexUsage(1)] + ]) + resetOperations = new Map() + resetOfferOwners = new Map() +} + +resetMockAccountState(fixtureStartedAt) + +export function selectMockClaudeAccount(accountId: unknown): void { + if (accountId === null) { + activeClaudeAccountId = null + return + } + if (accountId !== 'claude-team' && accountId !== 'claude-personal') { + throw new Error('Unknown Claude account') + } + activeClaudeAccountId = accountId +} + +export function selectMockCodexAccount(accountId: unknown): void { + if (accountId === null) { + activeCodexAccountId = null + return + } + if ( + typeof accountId !== 'string' || + !CODEX_ACCOUNTS.some((account) => account.id === accountId) + ) { + throw new Error('Unknown Codex account') + } + activeCodexAccountId = accountId +} + +function codexLimitsFor(accountId: string | null) { + const usage = accountId ? codexUsageByAccount.get(accountId) : null + if (!usage) { + return { + provider: 'codex' as const, + session: null, + weekly: null, + rateLimitResetCredits: { availableCount: 0, totalEarnedCount: 0, nextExpiresAt: null }, + updatedAt: fixtureStartedAt, + error: 'No managed Codex account selected', + status: 'unavailable' as const + } + } + return { + provider: 'codex' as const, + session: { + usedPercent: usage.sessionUsedPercent, + windowMinutes: 300, + resetsAt: fixtureStartedAt + 90 * 60 * 1000, + resetDescription: null + }, + weekly: { + usedPercent: 77, + windowMinutes: 10_080, + resetsAt: fixtureStartedAt + 3 * 24 * 60 * 60 * 1000, + resetDescription: null + }, + rateLimitResetCredits: { + availableCount: usage.availableResetCredits, + totalEarnedCount: 2, + nextExpiresAt: usage.availableResetCredits > 0 ? usage.nextExpiresAt : null + }, + updatedAt: usage.updatedAt, + error: null, + status: 'ok' as const + } +} + +export function getMockCodexResetScope(): CodexResetCreditExpectedScope | null { + const account = CODEX_ACCOUNTS.find((candidate) => candidate.id === activeCodexAccountId) ?? null + return buildCodexResetCreditExpectedScope({ + target: { runtime: 'host', wslDistro: null }, + account, + limits: codexLimitsFor(activeCodexAccountId) + }) +} + +export function consumeMockCodexResetCredit( + idempotencyKey: unknown, + expectedScope: unknown +): + | { outcome: 'reset' | 'noCredit'; scope: CodexResetCreditExpectedScope } + | { + status: 'rejectedBeforeProvider' + retryDisposition: 'discardAttempt' + reason: 'offerChanged' + scope: CodexResetCreditExpectedScope + } { + if (typeof idempotencyKey !== 'string' || !UUID_PATTERN.test(idempotencyKey)) { + throw new Error('Invalid idempotencyKey') + } + if (!expectedScope || typeof expectedScope !== 'object') { + throw new Error('Missing expectedScope') + } + const suppliedScopeKey = JSON.stringify(expectedScope) + const previous = resetOperations.get(idempotencyKey) + if (previous) { + if (previous.scopeKey !== suppliedScopeKey) { + throw new Error('The reset operation belongs to a different account scope') + } + return { + outcome: previous.outcome, + scope: expectedScope as CodexResetCreditExpectedScope + } + } + + const currentScope = getMockCodexResetScope() + if (!currentScope || JSON.stringify(currentScope) !== suppliedScopeKey) { + return { + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope: expectedScope as CodexResetCreditExpectedScope + } + } + const offerKey = suppliedScopeKey + const owner = resetOfferOwners.get(offerKey) + if (owner && owner !== idempotencyKey) { + throw new Error('That reset offer is already being redeemed') + } + resetOfferOwners.set(offerKey, idempotencyKey) + + const usage = codexUsageByAccount.get(currentScope.accountId) + const outcome = usage && usage.availableResetCredits > 0 ? 'reset' : 'noCredit' + resetOperations.set(idempotencyKey, { scopeKey: suppliedScopeKey, outcome }) + if (usage && outcome === 'reset') { + usage.availableResetCredits = 0 + usage.sessionUsedPercent = 0 + usage.updatedAt += 1 + } + return { outcome, scope: currentScope } +} + +export function createMockAccountsSnapshot() { + const codexLimits = codexLimitsFor(activeCodexAccountId) + return { + claude: { + accounts: [ + { id: 'claude-team', email: 'dev@example.com', organizationName: 'Example Team' }, + { id: 'claude-personal', email: 'personal@example.com', organizationName: null } + ], + activeAccountId: activeClaudeAccountId + }, + codex: { + accounts: CODEX_ACCOUNTS.map((account) => ({ ...account })), + activeAccountId: activeCodexAccountId, + activeAccountIdsByRuntime: { host: activeCodexAccountId, wsl: {} } + }, + rateLimits: { + claude: { + provider: 'claude' as const, + session: { + usedPercent: 38, + windowMinutes: 300, + resetsAt: fixtureStartedAt + 2 * 60 * 60 * 1000, + resetDescription: null + }, + weekly: { + usedPercent: 61, + windowMinutes: 10_080, + resetsAt: fixtureStartedAt + 4 * 24 * 60 * 60 * 1000, + resetDescription: null + }, + updatedAt: fixtureStartedAt, + error: null, + status: 'ok' as const + }, + codex: codexLimits, + claudeTarget: { runtime: 'host' as const, wslDistro: null }, + codexTarget: { runtime: 'host' as const, wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: CODEX_ACCOUNTS.filter( + (account) => account.id !== activeCodexAccountId + ).map((account) => ({ + accountId: account.id, + rateLimits: codexLimitsFor(account.id), + updatedAt: codexUsageByAccount.get(account.id)?.updatedAt ?? fixtureStartedAt, + isFetching: false + })) + } + } +} diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index 6e889e5ac23..ccb8c1c721f 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -10,7 +10,12 @@ import { import type { TerminalQuickCommand } from '../../src/shared/types' import { handleMockFilePreviewRequest } from './mock-server-file-preview-data' import { handleMockGitRequest } from './mock-server-git-state' -import { FAKE_SCROLLBACK, STREAMING_CHUNKS } from './mock-server-terminal-fixtures' +import { handleMockAccountRequest } from './mock-server-account-rpc' +import { + createMockTerminals, + FAKE_SCROLLBACK, + STREAMING_CHUNKS +} from './mock-server-terminal-fixtures' import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario' const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2) @@ -41,23 +46,6 @@ let fakeQuickCommands: TerminalQuickCommand[] = [ } ] -const FAKE_TERMINALS = [ - { - handle: 'term-1', - worktreeId: fakeWorktrees[0]?.worktreeId ?? 'repo-1::/tmp/orca-mobile-repro/orca', - title: 'Claude — auth refactor', - isActive: true, - hasRunningProcess: true - }, - { - handle: 'term-2', - worktreeId: fakeWorktrees[0]?.worktreeId ?? 'repo-1::/tmp/orca-mobile-repro/orca', - title: 'zsh', - isActive: false, - hasRunningProcess: false - } -] - export type RpcRequest = { id: string method: string @@ -109,6 +97,13 @@ function repoSelectorToId(repoSelector: unknown): string | null { return repoSelector.startsWith('id:') ? repoSelector.slice(3) : repoSelector } +function terminalListWorktreeId(worktreeSelector: unknown): string | undefined { + if (typeof worktreeSelector === 'string' && worktreeSelector.length > 0) { + return worktreeSelector.startsWith('id:') ? worktreeSelector.slice(3) : worktreeSelector + } + return fakeWorktrees.find((worktree) => worktree.isActive)?.worktreeId +} + export function handleRequest( request: RpcRequest, send: (response: RpcResponse) => void, @@ -129,6 +124,9 @@ export function handleRequest( if (handleMockFilePreviewRequest(request, respond, success, error)) { return } + if (handleMockAccountRequest(request, respond, success, error)) { + return + } switch (request.method) { case 'status.get': @@ -137,6 +135,7 @@ export function handleRequest( runtimeId: 'mock-runtime', protocolVersion: DESKTOP_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION, + capabilities: ['accounts.codex-reset-credit.v1'], graphStatus: 'ready', windowCount: 1, tabCount: 2, @@ -277,15 +276,17 @@ export function handleRequest( break } - case 'terminal.list': + case 'terminal.list': { + const terminals = createMockTerminals(terminalListWorktreeId(request.params?.worktree)) respond( success(request.id, { - terminals: FAKE_TERMINALS, - totalCount: FAKE_TERMINALS.length, + terminals, + totalCount: terminals.length, truncated: false }) ) break + } case 'terminal.subscribe': { respond(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) diff --git a/mobile/scripts/mock-server-terminal-fixtures.ts b/mobile/scripts/mock-server-terminal-fixtures.ts index fec2a45c1c2..f88a08529d9 100644 --- a/mobile/scripts/mock-server-terminal-fixtures.ts +++ b/mobile/scripts/mock-server-terminal-fixtures.ts @@ -19,3 +19,23 @@ export const STREAMING_CHUNKS = [ "I'll replace it with jsonwebtoken.\n", '\nUpdating src/auth/middleware.ts...\n' ] + +export function createMockTerminals(worktreeId?: string) { + const resolvedWorktreeId = worktreeId ?? 'repo-1::/tmp/orca-mobile-repro/orca' + return [ + { + handle: 'term-1', + worktreeId: resolvedWorktreeId, + title: 'Claude — auth refactor', + isActive: true, + hasRunningProcess: true + }, + { + handle: 'term-2', + worktreeId: resolvedWorktreeId, + title: 'zsh', + isActive: false, + hasRunningProcess: false + } + ] +} diff --git a/mobile/src/accounts-route-reset-credit.test.ts b/mobile/src/accounts-route-reset-credit.test.ts new file mode 100644 index 00000000000..76be8071930 --- /dev/null +++ b/mobile/src/accounts-route-reset-credit.test.ts @@ -0,0 +1,442 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AccountsScreen from '../app/h/[hostId]/accounts' +import { resetCodexResetAttemptJournalForTests } from './storage/codex-reset-attempt-journal' + +const dependencies = vi.hoisted(() => ({ + alert: vi.fn(), + back: vi.fn(), + loadHosts: vi.fn(), + randomUUID: vi.fn(), + resetRequest: vi.fn(), + selectRequest: vi.fn(), + statusCapabilities: vi.fn(), + subscriptionListeners: [] as Array<(payload: unknown) => void>, + asyncStorage: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() + } +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: dependencies.asyncStorage +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Alert: { alert: dependencies.alert }, + AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) }, + Pressable: 'Pressable', + RefreshControl: 'RefreshControl', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView', + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }) +})) + +vi.mock('expo-router', async () => { + const React = await import('react') + return { + useFocusEffect(effect: () => void | (() => void)): void { + React.useEffect(effect, [effect]) + }, + useLocalSearchParams: () => ({ hostId: 'host-1' }), + useRouter: () => ({ back: dependencies.back }) + } +}) + +vi.mock('expo-crypto', () => ({ randomUUID: dependencies.randomUUID })) + +vi.mock('lucide-react-native', () => ({ + Check: 'Check', + ChevronLeft: 'ChevronLeft', + RefreshCw: 'RefreshCw', + RotateCcw: 'RotateCcw', + User: 'User' +})) + +vi.mock('./transport/host-store', () => ({ loadHosts: dependencies.loadHosts })) + +vi.mock('./transport/client-context', () => { + const client = { + sendRequest: async (method: string, params?: unknown, options?: unknown) => { + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: { capabilities: dependencies.statusCapabilities() }, + _meta: { runtimeId: 'runtime-1' } + } + } + if (method === 'accounts.consumeCodexResetCredit') { + return dependencies.resetRequest(params, options) + } + if ( + method === 'accounts.selectCodex' || + method === 'accounts.selectCodexForTarget' || + method === 'accounts.selectClaude' + ) { + return dependencies.selectRequest(method, params) + } + if (method === 'accounts.list') { + return { id: 'list', ok: true, result: AVAILABLE_SNAPSHOT } + } + throw new Error(`Unexpected request: ${method}`) + }, + subscribe: (_method: string, _params: unknown, onData: (payload: unknown) => void) => { + dependencies.subscriptionListeners.push(onData) + onData({ type: 'ready', snapshot: AVAILABLE_SNAPSHOT }) + return vi.fn() + } + } + return { + useHostClient: () => ({ client, state: 'connected' }) + } +}) + +vi.mock('./components/AgentIcons', () => ({ + ClaudeIcon: 'ClaudeIcon', + OpenAIIcon: 'OpenAIIcon' +})) + +const AVAILABLE_SNAPSHOT = { + claude: { accounts: [], activeAccountId: null }, + codex: { + accounts: [ + { + id: 'codex-1', + email: 'dev@example.com', + managedHomeRuntime: 'host', + wslDistro: null, + updatedAt: 10 + } + ], + activeAccountId: 'codex-1', + activeAccountIdsByRuntime: { host: 'codex-1', wsl: {} } + }, + rateLimits: { + claude: null, + codex: { + provider: 'codex', + session: { + usedPercent: 100, + windowMinutes: 300, + resetsAt: 2_000_000_000_000, + resetDescription: null + }, + weekly: null, + rateLimitResetCredits: { availableCount: 1, nextExpiresAt: null }, + updatedAt: 100, + error: null, + status: 'ok' + }, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } +} as const + +const RESET_SNAPSHOT = { + ...AVAILABLE_SNAPSHOT, + rateLimits: { + ...AVAILABLE_SNAPSHOT.rateLimits, + codex: { + ...AVAILABLE_SNAPSHOT.rateLimits.codex, + session: { ...AVAILABLE_SNAPSHOT.rateLimits.codex.session, usedPercent: 0 }, + rateLimitResetCredits: { availableCount: 0, nextExpiresAt: null }, + updatedAt: 101 + } + } +} as const + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +async function renderAccountsRoute(): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(AccountsScreen)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('Accounts route did not render') + } + return renderer +} + +function resetButtons(renderer: ReactTestRenderer) { + return renderer.root + .findAllByType('Pressable') + .filter((node) => node.props.accessibilityLabel === 'Use Codex rate-limit reset') +} + +function systemDefaultButtons(renderer: ReactTestRenderer) { + return renderer.root + .findAllByType('Pressable') + .filter((node) => + node.findAllByType('Text').some((textNode) => textNode.children.join('') === 'System default') + ) +} + +async function findResetButton(renderer: ReactTestRenderer) { + await vi.waitFor(() => expect(resetButtons(renderer)).toHaveLength(1)) + return resetButtons(renderer)[0]! +} + +function getLatestConfirmAction(): () => void { + const call = dependencies.alert.mock.calls + .toReversed() + .find(([title]) => title === 'Use a rate-limit reset?') + const action = call?.[2]?.[1]?.onPress + if (typeof action !== 'function') { + throw new Error('Reset confirmation action not found') + } + return action +} + +async function confirmReset(renderer: ReactTestRenderer): Promise { + const button = await findResetButton(renderer) + await act(async () => button.props.onPress()) + await act(async () => { + getLatestConfirmAction()() + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('accounts route Codex reset credit', () => { + let storedValues: Map + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + resetCodexResetAttemptJournalForTests() + storedValues = new Map() + dependencies.alert.mockReset() + dependencies.loadHosts.mockReset().mockResolvedValue([ + { + id: 'host-1', + name: 'Desk', + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'public-key', + lastConnected: 1 + } + ]) + dependencies.randomUUID.mockReset().mockReturnValue('11111111-1111-4111-8111-111111111111') + dependencies.statusCapabilities.mockReset().mockReturnValue(['accounts.codex-reset-credit.v1']) + dependencies.resetRequest.mockReset().mockImplementation((params) => ({ + id: 'reset', + ok: true, + result: { + outcome: 'reset', + scope: (params as { expectedScope: unknown }).expectedScope, + snapshot: RESET_SNAPSHOT + }, + _meta: { runtimeId: 'runtime-1' } + })) + dependencies.selectRequest.mockReset().mockResolvedValue({ + id: 'select', + ok: true, + result: AVAILABLE_SNAPSHOT.codex + }) + dependencies.subscriptionListeners.length = 0 + dependencies.asyncStorage.getItem + .mockReset() + .mockImplementation(async (key: string) => storedValues.get(key) ?? null) + dependencies.asyncStorage.setItem + .mockReset() + .mockImplementation(async (key: string, value: string) => { + storedValues.set(key, value) + }) + dependencies.asyncStorage.removeItem.mockReset().mockImplementation(async (key: string) => { + storedValues.delete(key) + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('hides the scarce action when an older host does not advertise the capability', async () => { + dependencies.statusCapabilities.mockReturnValue([]) + const renderer = await renderAccountsRoute() + await act(async () => { + await Promise.resolve() + }) + + expect(resetButtons(renderer)).toHaveLength(0) + expect(dependencies.resetRequest).not.toHaveBeenCalled() + act(() => renderer.unmount()) + }) + + it('persists before RPC and reuses the UUID after unmounting an ambiguous request', async () => { + dependencies.resetRequest + .mockRejectedValueOnce(new Error('Connection lost')) + .mockImplementationOnce((params) => ({ + id: 'reset-2', + ok: true, + result: { + outcome: 'alreadyRedeemed', + scope: (params as { expectedScope: unknown }).expectedScope, + snapshot: RESET_SNAPSHOT + }, + _meta: { runtimeId: 'runtime-1' } + })) + + const firstRenderer = await renderAccountsRoute() + await confirmReset(firstRenderer) + expect(dependencies.alert).toHaveBeenCalledWith( + 'Could not reset rate limits', + 'Connection lost' + ) + expect(storedValues.size).toBe(1) + act(() => firstRenderer.unmount()) + + resetCodexResetAttemptJournalForTests() + const secondRenderer = await renderAccountsRoute() + await confirmReset(secondRenderer) + + expect(dependencies.randomUUID).toHaveBeenCalledTimes(1) + expect(dependencies.resetRequest).toHaveBeenCalledTimes(2) + const [firstParams, firstOptions] = dependencies.resetRequest.mock.calls[0]! + const [secondParams, secondOptions] = dependencies.resetRequest.mock.calls[1]! + expect(firstParams).toEqual(secondParams) + expect(firstOptions).toEqual({ timeoutMs: 90_000 }) + expect(secondOptions).toEqual({ timeoutMs: 90_000 }) + expect(storedValues.size).toBe(0) + expect(dependencies.alert).toHaveBeenCalledWith( + 'Reset already applied', + 'Codex usage has been refreshed.' + ) + act(() => secondRenderer.unmount()) + }) + + it('keeps the exact confirmed scope when a subscription changes before confirmation', async () => { + const renderer = await renderAccountsRoute() + const button = await findResetButton(renderer) + await act(async () => button.props.onPress()) + const action = getLatestConfirmAction() + + const changedSnapshot = { + ...AVAILABLE_SNAPSHOT, + codex: { + ...AVAILABLE_SNAPSHOT.codex, + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + } + dependencies.resetRequest.mockImplementation((params) => ({ + id: 'reset', + ok: true, + result: { + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'accountChanged', + scope: (params as { expectedScope: unknown }).expectedScope, + snapshot: changedSnapshot + }, + _meta: { runtimeId: 'runtime-1' } + })) + act(() => { + dependencies.subscriptionListeners[0]?.({ type: 'snapshot', snapshot: changedSnapshot }) + }) + await act(async () => { + action() + await Promise.resolve() + await Promise.resolve() + }) + + expect(dependencies.resetRequest).toHaveBeenCalledOnce() + expect(dependencies.resetRequest.mock.calls[0]?.[0]).toMatchObject({ + expectedScope: { accountId: 'codex-1', accountRevision: 10 } + }) + expect(dependencies.alert).toHaveBeenCalledWith( + 'Reset details changed', + 'The account or reset offer changed before the host contacted Codex. Review the updated details, then confirm again.' + ) + expect(storedValues.size).toBe(0) + act(() => renderer.unmount()) + }) + + it('passes the active WSL target when clearing the Codex selection', async () => { + const renderer = await renderAccountsRoute() + const wslSnapshot = { + ...AVAILABLE_SNAPSHOT, + codex: { + accounts: [ + { + ...AVAILABLE_SNAPSHOT.codex.accounts[0], + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu' + } + ], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'codex-1' } } + }, + rateLimits: { + ...AVAILABLE_SNAPSHOT.rateLimits, + codexTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' } + } + } as const + + act(() => { + dependencies.subscriptionListeners[0]?.({ type: 'snapshot', snapshot: wslSnapshot }) + }) + const codexSystemDefault = systemDefaultButtons(renderer).at(-1) + expect(codexSystemDefault).toBeDefined() + + await act(async () => { + await codexSystemDefault?.props.onPress() + }) + + expect(dependencies.selectRequest).toHaveBeenCalledWith('accounts.selectCodexForTarget', { + accountId: null, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }) + act(() => renderer.unmount()) + }) + + it('recovers from UUID generation failure without leaving the action busy', async () => { + dependencies.randomUUID + .mockImplementationOnce(() => { + throw new Error('UUID unavailable') + }) + .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') + const renderer = await renderAccountsRoute() + + await confirmReset(renderer) + expect(dependencies.alert).toHaveBeenCalledWith( + 'Could not reset rate limits', + 'UUID unavailable' + ) + expect((await findResetButton(renderer)).props.accessibilityState).toEqual({ + busy: false, + disabled: false + }) + + await confirmReset(renderer) + expect(dependencies.resetRequest).toHaveBeenCalledOnce() + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 713a2dd8a46..3b1115fed2f 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -14,6 +14,7 @@ export type { UsageBarState } from './account-usage-state' export { + decodeAccountsSnapshot, getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, diff --git a/mobile/src/components/CodexResetCreditAction.test.ts b/mobile/src/components/CodexResetCreditAction.test.ts new file mode 100644 index 00000000000..25e39591701 --- /dev/null +++ b/mobile/src/components/CodexResetCreditAction.test.ts @@ -0,0 +1,87 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CodexResetCreditAction } from './CodexResetCreditAction' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ RotateCcw: 'RotateCcw' })) + +const summary = { + availableCount: 1, + availabilityLabel: '1 reset available', + expiryLabel: 'Expires in 5d' +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer { + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create( + createElement(CodexResetCreditAction, { + summary, + scopeLabel: 'dev@example.com on the host', + busy, + disabled, + onPress: vi.fn() + }) + ) + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('Reset action did not render') + } + return renderer +} + +describe('CodexResetCreditAction', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('exposes a 44pt touch target and enabled accessibility state', () => { + const renderer = renderAction(false, false) + const button = renderer.root.findByType('Pressable') + + expect(button.props.accessibilityLabel).toBe('Use Codex rate-limit reset') + expect(button.props.accessibilityState).toEqual({ busy: false, disabled: false }) + expect(button.props.accessibilityHint).toContain('dev@example.com on the host') + expect(button.props.hitSlop).toBe(8) + expect(button.props.style({ pressed: false })[0]).toMatchObject({ minHeight: 44 }) + act(() => renderer.unmount()) + }) + + it('announces progress and visually dims a busy disabled action', () => { + const renderer = renderAction(true, true) + const button = renderer.root.findByType('Pressable') + const text = renderer.root + .findAllByType('Text') + .map((node) => node.children.filter((child) => typeof child === 'string').join('')) + + expect(button.props.accessibilityLabel).toBe('Resetting Codex rate limits') + expect(button.props.accessibilityState).toEqual({ busy: true, disabled: true }) + expect(button.props.style({ pressed: false })[1]).toMatchObject({ opacity: 0.5 }) + expect(text).toContain('Resetting…') + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/components/CodexResetCreditAction.tsx b/mobile/src/components/CodexResetCreditAction.tsx new file mode 100644 index 00000000000..abbec6f2e8e --- /dev/null +++ b/mobile/src/components/CodexResetCreditAction.tsx @@ -0,0 +1,110 @@ +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { RotateCcw } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { CodexResetCreditSummary } from './codex-reset-credit' + +export function CodexResetCreditAction({ + summary, + scopeLabel, + busy, + disabled, + onPress +}: { + summary: CodexResetCreditSummary + scopeLabel?: string | null + busy: boolean + disabled: boolean + onPress: () => void +}) { + return ( + <> + + + + {summary.availabilityLabel} + + {[summary.expiryLabel, scopeLabel].filter(Boolean).join(' · ') || + 'Earned Codex rate-limit reset'} + + + [ + styles.button, + disabled && styles.buttonDisabled, + pressed && !disabled && styles.buttonPressed + ]} + onPress={onPress} + disabled={disabled} + accessibilityRole="button" + accessibilityLabel={busy ? 'Resetting Codex rate limits' : 'Use Codex rate-limit reset'} + accessibilityHint={ + scopeLabel + ? `Uses one earned reset for ${scopeLabel}` + : 'Uses one earned reset for the active Codex account' + } + accessibilityState={{ busy, disabled }} + hitSlop={8} + > + {busy ? ( + + ) : ( + + )} + {busy ? 'Resetting…' : 'Use reset'} + + + + ) +} + +const styles = StyleSheet.create({ + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + copy: { + flex: 1, + gap: spacing.xs + }, + title: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + subtitle: { + fontSize: typography.metaSize, + color: colors.textSecondary + }, + button: { + minHeight: 44, + width: 104, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + buttonPressed: { + opacity: 0.72 + }, + buttonDisabled: { + opacity: 0.5 + }, + buttonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + } +}) diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts index 3e128b7b099..3f019ebfef4 100644 --- a/mobile/src/components/account-usage-state.test.ts +++ b/mobile/src/components/account-usage-state.test.ts @@ -35,11 +35,21 @@ function makeSnapshot( } = {} ): AccountsSnapshot { return { - claude: { accounts: overrides.claudeAccounts ?? [], activeAccountId: null }, - codex: { accounts: overrides.codexAccounts ?? [], activeAccountId: null }, + claude: { + accounts: overrides.claudeAccounts ?? [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + }, + codex: { + accounts: overrides.codexAccounts ?? [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + }, rateLimits: { claude: overrides.claudeLimits ?? null, codex: overrides.codexLimits ?? null, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: overrides.inactiveClaudeAccounts ?? [], inactiveCodexAccounts: overrides.inactiveCodexAccounts ?? [] } diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index fa5b4a516b3..3987364a0f9 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -6,54 +6,25 @@ // unit-tested directly; AccountUsage.tsx re-exports them alongside the // UsageBar component. import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format' +import type { + AccountsSnapshot, + InactiveAccountUsage, + ProviderRateLimits +} from './accounts-snapshot' -export type RateLimitWindow = { - usedPercent: number - windowMinutes: number - resetsAt: number | null - resetDescription: string | null -} - -export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' - session: RateLimitWindow | null - weekly: RateLimitWindow | null - monthly?: RateLimitWindow | null - buckets?: Array - updatedAt: number - error: string | null - status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' -} - -export type InactiveAccountUsage = { - accountId: string - rateLimits: ProviderRateLimits | null - updatedAt: number - isFetching: boolean -} - -export type ClaudeAccountSummary = { - id: string - email: string - organizationName?: string | null -} - -export type CodexAccountSummary = { - id: string - email: string - workspaceLabel?: string | null -} - -export type AccountsSnapshot = { - claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null } - codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null } - rateLimits: { - claude: ProviderRateLimits | null - codex: ProviderRateLimits | null - inactiveClaudeAccounts: InactiveAccountUsage[] - inactiveCodexAccounts: InactiveAccountUsage[] - } -} +export { + AccountsSnapshotSchema, + decodeAccountsSnapshot, + ProviderRateLimitsSchema, + RateLimitRuntimeTargetSchema, + type AccountsSnapshot, + type ClaudeAccountSummary, + type CodexAccountSummary, + type InactiveAccountUsage, + type ProviderRateLimits, + type RateLimitRuntimeTarget, + type RateLimitWindow +} from './accounts-snapshot' export type ProviderKey = 'claude' | 'codex' diff --git a/mobile/src/components/accounts-snapshot.test.ts b/mobile/src/components/accounts-snapshot.test.ts new file mode 100644 index 00000000000..89e0bf84934 --- /dev/null +++ b/mobile/src/components/accounts-snapshot.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' + +import { decodeAccountsSnapshot } from './accounts-snapshot' + +function makeSnapshot(): unknown { + return { + extensionField: { retained: true }, + claude: { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + }, + codex: { + accounts: [ + { + id: 'codex-host', + email: 'host@example.com', + managedHomeRuntime: 'host', + wslDistro: null, + updatedAt: 100, + extensionField: 'account-extra' + } + ], + activeAccountId: 'codex-host', + activeAccountIdsByRuntime: { + host: 'codex-host', + wsl: { Ubuntu: 'codex-wsl' } + } + }, + rateLimits: { + extensionField: 'limits-extra', + claude: null, + codex: { + provider: 'codex', + session: { + usedPercent: 100, + windowMinutes: 300, + resetsAt: 200, + resetDescription: 'soon' + }, + weekly: null, + rateLimitResetCredits: { + availableCount: 1, + totalEarnedCount: 2, + nextExpiresAt: 300, + credits: [{ status: 'available', expiresAt: 300, grantedAt: 50 }] + }, + updatedAt: 100, + error: null, + status: 'ok', + extensionField: 'provider-extra' + }, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [ + { + accountId: 'codex-inactive', + rateLimits: null, + updatedAt: 99, + isFetching: false + } + ] + } + } +} + +function setPath(root: unknown, path: string[], value: unknown): void { + let current: unknown = root + for (const segment of path.slice(0, -1)) { + if (!current || typeof current !== 'object' || Array.isArray(current)) { + throw new Error(`Invalid fixture path: ${path.join('.')}`) + } + current = (current as Record)[segment] + } + if (!current || typeof current !== 'object' || Array.isArray(current)) { + throw new Error(`Invalid fixture path: ${path.join('.')}`) + } + const record = current as Record + record[path.at(-1)!] = value +} + +describe('decodeAccountsSnapshot', () => { + it('validates nested account/rate-limit state and preserves forward-compatible fields', () => { + const snapshot = decodeAccountsSnapshot(makeSnapshot()) + + expect(snapshot.extensionField).toEqual({ retained: true }) + expect(snapshot.codex.accounts[0]?.extensionField).toBe('account-extra') + expect(snapshot.rateLimits.extensionField).toBe('limits-extra') + expect(snapshot.rateLimits.codex?.extensionField).toBe('provider-extra') + }) + + it('defaults missing runtime targets for older host-only snapshots', () => { + const raw = makeSnapshot() as { + rateLimits: { claudeTarget?: unknown; codexTarget?: unknown } + } + delete raw.rateLimits.claudeTarget + delete raw.rateLimits.codexTarget + + const snapshot = decodeAccountsSnapshot(raw) + + expect(snapshot.rateLimits.claudeTarget).toEqual({ runtime: 'host', wslDistro: null }) + expect(snapshot.rateLimits.codexTarget).toEqual({ runtime: 'host', wslDistro: null }) + }) + + it.each([ + ['account arrays', ['codex', 'accounts'], {}], + ['active account IDs', ['codex', 'activeAccountId'], 42], + ['runtime selections', ['codex', 'activeAccountIdsByRuntime', 'wsl'], []], + ['targets', ['rateLimits', 'codexTarget', 'runtime'], 'remote'], + ['provider identity', ['rateLimits', 'codex', 'provider'], 'claude'], + ['inactive account arrays', ['rateLimits', 'inactiveCodexAccounts'], {}], + ['window percentages', ['rateLimits', 'codex', 'session', 'usedPercent'], 101], + ['credit counts', ['rateLimits', 'codex', 'rateLimitResetCredits', 'availableCount'], -1], + [ + 'credit status', + ['rateLimits', 'codex', 'rateLimitResetCredits', 'credits'], + [{ status: '', expiresAt: 300, grantedAt: 50 }] + ], + ['credit expiry', ['rateLimits', 'codex', 'rateLimitResetCredits', 'nextExpiresAt'], 'soon'] + ] satisfies Array<[string, string[], unknown]>)('rejects malformed %s', (_name, path, value) => { + const snapshot = makeSnapshot() + setPath(snapshot, path, value) + + expect(() => decodeAccountsSnapshot(snapshot)).toThrow('Invalid accounts snapshot from host') + }) + + it('rejects a host target that smuggles a WSL distro', () => { + const snapshot = makeSnapshot() + setPath(snapshot, ['rateLimits', 'codexTarget', 'wslDistro'], 'Ubuntu') + + expect(() => decodeAccountsSnapshot(snapshot)).toThrow('Invalid accounts snapshot from host') + }) +}) diff --git a/mobile/src/components/accounts-snapshot.ts b/mobile/src/components/accounts-snapshot.ts new file mode 100644 index 00000000000..8baf0213623 --- /dev/null +++ b/mobile/src/components/accounts-snapshot.ts @@ -0,0 +1,236 @@ +import { z } from 'zod' + +const TimestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +const AccountIdSchema = z.string().min(1) + +const RateLimitWindowSchema = z + .object({ + usedPercent: z.number().finite().min(0).max(100), + windowMinutes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + resetsAt: TimestampSchema.nullable(), + resetDescription: z.string().nullable() + }) + .passthrough() + +const RateLimitResetCreditSchema = z + .object({ + status: z.string().min(1), + expiresAt: TimestampSchema.nullable(), + grantedAt: TimestampSchema.nullable() + }) + .passthrough() + +const RateLimitResetCreditsSchema = z + .object({ + availableCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + totalEarnedCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + nextExpiresAt: TimestampSchema.nullable().optional(), + credits: z.array(RateLimitResetCreditSchema).optional() + }) + .passthrough() + +export const ProviderRateLimitsSchema = z + .object({ + provider: z.enum([ + 'claude', + 'codex', + 'gemini', + 'opencode-go', + 'kimi', + 'minimax', + 'grok', + 'antigravity' + ]), + session: RateLimitWindowSchema.nullable(), + weekly: RateLimitWindowSchema.nullable(), + fableWeekly: RateLimitWindowSchema.nullable().optional(), + monthly: RateLimitWindowSchema.nullable().optional(), + buckets: z + .array(RateLimitWindowSchema.extend({ name: z.string().min(1) }).passthrough()) + .optional(), + rateLimitResetCredits: RateLimitResetCreditsSchema.nullable().optional(), + updatedAt: TimestampSchema, + error: z.string().nullable(), + status: z.enum(['idle', 'fetching', 'ok', 'error', 'unavailable']) + }) + .passthrough() + +const InactiveAccountUsageSchema = z + .object({ + accountId: AccountIdSchema, + rateLimits: ProviderRateLimitsSchema.nullable(), + updatedAt: TimestampSchema, + isFetching: z.boolean() + }) + .passthrough() + +const RuntimeSelectionSchema = z + .object({ + host: AccountIdSchema.nullable(), + wsl: z.record(z.string().min(1), AccountIdSchema.nullable()) + }) + .passthrough() + +export const RateLimitRuntimeTargetSchema = z + .object({ + runtime: z.enum(['host', 'wsl']), + wslDistro: z.string().min(1).nullable() + }) + .passthrough() + .superRefine((target, context) => { + if (target.runtime === 'host' && target.wslDistro !== null) { + context.addIssue({ + code: 'custom', + message: 'Host rate-limit targets cannot name a WSL distro', + path: ['wslDistro'] + }) + } + if ( + target.runtime === 'wsl' && + target.wslDistro !== null && + target.wslDistro.trim() !== target.wslDistro + ) { + context.addIssue({ + code: 'custom', + message: 'WSL rate-limit targets require an exact distro', + path: ['wslDistro'] + }) + } + }) + +const HostRateLimitRuntimeTarget = { + runtime: 'host' as const, + wslDistro: null +} + +const ClaudeAccountSummarySchema = z + .object({ + id: AccountIdSchema, + email: z.string().min(1), + managedAuthRuntime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullable().optional(), + authMethod: z.enum(['subscription-oauth', 'unknown']).optional(), + organizationUuid: z.string().nullable().optional(), + organizationName: z.string().nullable().optional(), + createdAt: TimestampSchema.optional(), + updatedAt: TimestampSchema.optional(), + lastAuthenticatedAt: TimestampSchema.optional() + }) + .passthrough() + +const CodexAccountSummarySchema = z + .object({ + id: AccountIdSchema, + email: z.string().min(1), + managedHomeRuntime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullable().optional(), + providerAccountId: z.string().nullable().optional(), + workspaceLabel: z.string().nullable().optional(), + workspaceAccountId: z.string().nullable().optional(), + createdAt: TimestampSchema.optional(), + updatedAt: TimestampSchema, + lastAuthenticatedAt: TimestampSchema.optional() + }) + .passthrough() + .superRefine((account, context) => { + const runtime = account.managedHomeRuntime ?? 'host' + if (runtime === 'host' && account.wslDistro != null) { + context.addIssue({ + code: 'custom', + message: 'Host Codex accounts cannot name a WSL distro', + path: ['wslDistro'] + }) + } + if ( + runtime === 'wsl' && + account.wslDistro != null && + account.wslDistro.trim() !== account.wslDistro + ) { + context.addIssue({ + code: 'custom', + message: 'WSL Codex accounts require an exact distro', + path: ['wslDistro'] + }) + } + }) + +export const AccountsSnapshotSchema = z + .object({ + claude: z + .object({ + accounts: z.array(ClaudeAccountSummarySchema), + activeAccountId: AccountIdSchema.nullable(), + activeAccountIdsByRuntime: RuntimeSelectionSchema.optional() + }) + .passthrough(), + codex: z + .object({ + accounts: z.array(CodexAccountSummarySchema), + activeAccountId: AccountIdSchema.nullable(), + activeAccountIdsByRuntime: RuntimeSelectionSchema.optional() + }) + .passthrough(), + rateLimits: z + .object({ + claude: ProviderRateLimitsSchema.nullable(), + codex: ProviderRateLimitsSchema.nullable(), + // Why: protocol-compatible hosts from before runtime targeting omit + // these fields; their account selection semantics were host-only. + claudeTarget: RateLimitRuntimeTargetSchema.default(HostRateLimitRuntimeTarget), + codexTarget: RateLimitRuntimeTargetSchema.default(HostRateLimitRuntimeTarget), + inactiveClaudeAccounts: z.array(InactiveAccountUsageSchema), + inactiveCodexAccounts: z.array(InactiveAccountUsageSchema) + }) + .passthrough() + }) + .passthrough() + .superRefine((snapshot, context) => { + if (snapshot.rateLimits.claude && snapshot.rateLimits.claude.provider !== 'claude') { + context.addIssue({ + code: 'custom', + message: 'Claude limits use the wrong provider identity', + path: ['rateLimits', 'claude', 'provider'] + }) + } + if (snapshot.rateLimits.codex && snapshot.rateLimits.codex.provider !== 'codex') { + context.addIssue({ + code: 'custom', + message: 'Codex limits use the wrong provider identity', + path: ['rateLimits', 'codex', 'provider'] + }) + } + for (const [index, entry] of snapshot.rateLimits.inactiveClaudeAccounts.entries()) { + if (entry.rateLimits && entry.rateLimits.provider !== 'claude') { + context.addIssue({ + code: 'custom', + message: 'Inactive Claude limits use the wrong provider identity', + path: ['rateLimits', 'inactiveClaudeAccounts', index, 'rateLimits', 'provider'] + }) + } + } + for (const [index, entry] of snapshot.rateLimits.inactiveCodexAccounts.entries()) { + if (entry.rateLimits && entry.rateLimits.provider !== 'codex') { + context.addIssue({ + code: 'custom', + message: 'Inactive Codex limits use the wrong provider identity', + path: ['rateLimits', 'inactiveCodexAccounts', index, 'rateLimits', 'provider'] + }) + } + } + }) + +export type RateLimitWindow = z.infer +export type ProviderRateLimits = z.infer +export type InactiveAccountUsage = z.infer +export type RateLimitRuntimeTarget = z.infer +export type ClaudeAccountSummary = z.infer +export type CodexAccountSummary = z.infer +export type AccountsSnapshot = z.infer + +export function decodeAccountsSnapshot(value: unknown): AccountsSnapshot { + const result = AccountsSnapshotSchema.safeParse(value) + if (!result.success) { + throw new Error('Invalid accounts snapshot from host') + } + return result.data +} diff --git a/mobile/src/components/codex-reset-credit-capability.test.ts b/mobile/src/components/codex-reset-credit-capability.test.ts new file mode 100644 index 00000000000..7afaa7d7b80 --- /dev/null +++ b/mobile/src/components/codex-reset-credit-capability.test.ts @@ -0,0 +1,80 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' + +const probe = vi.hoisted(() => ({ + start: vi.fn() +})) + +vi.mock('../transport/runtime-capability-probe', () => ({ + startRuntimeCapabilityProbe: probe.start +})) + +import { + MOBILE_CODEX_RESET_CREDIT_CAPABILITY, + readCodexResetCreditCapability, + useCodexResetCreditCapability +} from './codex-reset-credit-capability' + +afterEach(() => { + vi.restoreAllMocks() + probe.start.mockReset() +}) + +describe('readCodexResetCreditCapability', () => { + it('enables reset only when the host explicitly advertises the contract', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { capabilities: ['mobile.tasks.v1', MOBILE_CODEX_RESET_CREDIT_CAPABILITY] } + }) + + await expect(readCodexResetCreditCapability({ sendRequest })).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledWith('status.get') + }) + + it.each([ + { ok: true, result: { capabilities: ['mobile.tasks.v1'] } }, + { ok: true, result: { capabilities: 'accounts.codex-reset-credit.v1' } }, + { ok: false, error: { code: 'old-host', message: 'unsupported' } } + ])('fails closed for an unsupported or malformed host response', async (response) => { + const sendRequest = vi.fn().mockResolvedValue(response) + await expect(readCodexResetCreditCapability({ sendRequest })).resolves.toBe(false) + }) + + it('fails closed when the capability probe cannot complete', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('connection lost')) + await expect(readCodexResetCreditCapability({ sendRequest })).resolves.toBe(false) + }) +}) + +describe('useCodexResetCreditCapability', () => { + it('uses the reconnect-safe probe and cancels it on unmount', () => { + const cancel = vi.fn() + let publish: ((capabilities: readonly string[]) => void) | null = null + probe.start.mockImplementation( + (_client: RpcClient, onCapabilities: (capabilities: readonly string[]) => void) => { + publish = onCapabilities + return cancel + } + ) + const client = { sendRequest: vi.fn() } as unknown as RpcClient + let renderer: ReactTestRenderer | null = null + + function Harness() { + const supported = useCodexResetCreditCapability(client, true) + return createElement('CapabilityResult', { supported }) + } + + act(() => { + renderer = create(createElement(Harness)) + }) + expect(renderer!.root.findByType('CapabilityResult').props.supported).toBe(false) + + act(() => publish?.([MOBILE_CODEX_RESET_CREDIT_CAPABILITY])) + expect(renderer!.root.findByType('CapabilityResult').props.supported).toBe(true) + + act(() => renderer!.unmount()) + expect(cancel).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/components/codex-reset-credit-capability.ts b/mobile/src/components/codex-reset-credit-capability.ts new file mode 100644 index 00000000000..1a32ef37873 --- /dev/null +++ b/mobile/src/components/codex-reset-credit-capability.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react' +import { CODEX_RESET_CREDIT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import type { RpcClient } from '../transport/rpc-client' +import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' + +// Why: source the capability string from the shared contract so a host bump can never +// silently drift from the mobile probe. +export const MOBILE_CODEX_RESET_CREDIT_CAPABILITY = CODEX_RESET_CREDIT_RUNTIME_CAPABILITY + +export async function readCodexResetCreditCapability( + client: Pick +): Promise { + try { + const response = await client.sendRequest('status.get') + if (!response.ok || !response.result || typeof response.result !== 'object') { + return false + } + const capabilities = (response.result as { capabilities?: unknown }).capabilities + return ( + Array.isArray(capabilities) && capabilities.includes(MOBILE_CODEX_RESET_CREDIT_CAPABILITY) + ) + } catch { + return false + } +} + +export function useCodexResetCreditCapability( + client: RpcClient | null, + connected: boolean +): boolean { + const [supported, setSupported] = useState(false) + + useEffect(() => { + setSupported(false) + if (!client || !connected) { + return + } + return startRuntimeCapabilityProbe(client, (capabilities) => { + setSupported(capabilities.includes(MOBILE_CODEX_RESET_CREDIT_CAPABILITY)) + }) + }, [client, connected]) + + return supported +} diff --git a/mobile/src/components/codex-reset-credit.test.ts b/mobile/src/components/codex-reset-credit.test.ts new file mode 100644 index 00000000000..7434dc6f0ed --- /dev/null +++ b/mobile/src/components/codex-reset-credit.test.ts @@ -0,0 +1,542 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +import { resetCodexResetAttemptJournalForTests } from '../storage/codex-reset-attempt-journal' +import type { AccountsSnapshot, ProviderRateLimits } from './accounts-snapshot' +import { + getActiveCodexAccountIdForRateLimitTarget, + getCodexResetCreditOutcomeCopy, + getCodexResetCreditScope, + getCodexResetCreditSummary, + resetCodexResetCreditRequestsForTests, + requestCodexResetCredit +} from './codex-reset-credit' + +const UUID = '11111111-1111-4111-8111-111111111111' + +function makeLimits(availableCount: number, nextExpiresAt: number | null): ProviderRateLimits { + return { + provider: 'codex', + session: null, + weekly: null, + rateLimitResetCredits: { availableCount, nextExpiresAt }, + updatedAt: 100, + error: null, + status: 'ok' + } +} + +function makeSnapshot( + options: { + target?: AccountsSnapshot['rateLimits']['codexTarget'] + activeHostId?: string | null + activeWslIds?: Record + accounts?: AccountsSnapshot['codex']['accounts'] + availableCount?: number + } = {} +): AccountsSnapshot { + const activeHostId = options.activeHostId === undefined ? 'account-host' : options.activeHostId + return { + claude: { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + }, + codex: { + accounts: options.accounts ?? [ + { + id: 'account-host', + email: 'host@example.com', + managedHomeRuntime: 'host', + wslDistro: null, + updatedAt: 10 + } + ], + activeAccountId: activeHostId, + activeAccountIdsByRuntime: { + host: activeHostId, + wsl: options.activeWslIds ?? {} + } + }, + rateLimits: { + claude: null, + codex: makeLimits(options.availableCount ?? 1, null), + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: options.target ?? { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } + } +} + +describe('getCodexResetCreditSummary', () => { + const now = 1_700_000_000_000 + + it('hides the action when no earned credit is available', () => { + expect(getCodexResetCreditSummary(null, now)).toBeNull() + expect(getCodexResetCreditSummary(makeLimits(0, now + 60_000), now)).toBeNull() + }) + + it('formats singular and plural availability with the next expiry', () => { + expect(getCodexResetCreditSummary(makeLimits(1, now + 2 * 60 * 60_000), now)).toEqual({ + availableCount: 1, + availabilityLabel: '1 reset available', + expiryLabel: 'Expires in 2h' + }) + expect(getCodexResetCreditSummary(makeLimits(2, now + 90 * 60_000), now)).toEqual({ + availableCount: 2, + availabilityLabel: '2 resets available', + expiryLabel: 'Next expires in 1h 30m' + }) + }) +}) + +describe('Codex reset credit scope', () => { + it('binds a host offer to the exact managed active account and revision', () => { + const snapshot = makeSnapshot() + + expect(getActiveCodexAccountIdForRateLimitTarget(snapshot)).toBe('account-host') + expect(getCodexResetCreditScope(snapshot)).toMatchObject({ + target: { runtime: 'host', wslDistro: null }, + accountId: 'account-host', + accountRevision: 10, + offerRevision: expect.stringMatching(/^v1:/) + }) + }) + + it('binds a WSL offer only to the exact distro selection and account', () => { + const snapshot = makeSnapshot({ + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + activeWslIds: { Ubuntu: 'account-wsl', Debian: 'account-debian' }, + accounts: [ + { + id: 'account-wsl', + email: 'wsl@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + updatedAt: 20 + }, + { + id: 'account-debian', + email: 'debian@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Debian', + updatedAt: 30 + } + ] + }) + + expect(getActiveCodexAccountIdForRateLimitTarget(snapshot)).toBe('account-wsl') + expect(getCodexResetCreditScope(snapshot)).toMatchObject({ + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + accountId: 'account-wsl', + accountRevision: 20 + }) + }) + + it('fails closed for system-default, unknown WSL distro, and account/target mismatch', () => { + const systemDefault = makeSnapshot({ activeHostId: null }) + expect(getActiveCodexAccountIdForRateLimitTarget(systemDefault)).toBeNull() + expect(getCodexResetCreditScope(systemDefault)).toBeNull() + + const unknownDistro = makeSnapshot({ + target: { runtime: 'wsl', wslDistro: null }, + activeWslIds: { __default__: 'account-host' } + }) + expect(getActiveCodexAccountIdForRateLimitTarget(unknownDistro)).toBeNull() + expect(getCodexResetCreditScope(unknownDistro)).toBeNull() + + const mismatch = makeSnapshot({ + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + activeWslIds: { Ubuntu: 'account-host' } + }) + expect(getCodexResetCreditScope(mismatch)).toBeNull() + }) +}) + +describe('getCodexResetCreditOutcomeCopy', () => { + it.each([ + ['reset', 'Rate limits reset', 'Codex usage has been refreshed.'], + ['alreadyRedeemed', 'Reset already applied', 'Codex usage has been refreshed.'], + ['nothingToReset', 'Nothing to reset', 'No eligible Codex rate-limit window is exhausted.'], + ['noCredit', 'No reset available', 'This account has no earned reset credits available.'] + ] as const)('maps %s to user-facing copy', (outcome, title, message) => { + expect(getCodexResetCreditOutcomeCopy(outcome)).toEqual({ title, message }) + }) +}) + +describe('requestCodexResetCredit', () => { + let values: Map + + beforeEach(() => { + vi.clearAllMocks() + resetCodexResetAttemptJournalForTests() + resetCodexResetCreditRequestsForTests() + values = new Map() + asyncStorage.getItem.mockImplementation(async (key: string) => values.get(key) ?? null) + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + values.set(key, value) + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + values.delete(key) + }) + }) + + it('persists before RPC, sends the exact scope with a 90s timeout, then clears', async () => { + const snapshot = makeSnapshot() + const expectedScope = getCodexResetCreditScope(snapshot)! + const sendRequest = vi.fn().mockResolvedValue({ + id: 'request-1', + ok: true, + result: { outcome: 'reset', scope: expectedScope, snapshot }, + _meta: { runtimeId: 'runtime-1' } + }) + + await expect( + requestCodexResetCredit( + { sendRequest }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).resolves.toEqual({ + outcome: 'reset', + scope: expectedScope, + snapshot, + attemptJournalRetained: false + }) + expect(asyncStorage.setItem.mock.invocationCallOrder[0]).toBeLessThan( + sendRequest.mock.invocationCallOrder[0]! + ) + expect(sendRequest).toHaveBeenCalledWith( + 'accounts.consumeCodexResetCredit', + { idempotencyKey: UUID, expectedScope }, + { timeoutMs: 90_000 } + ) + expect(values.size).toBe(0) + }) + + it('replays the original scope and UUID after an ambiguous response and offer refresh', async () => { + const snapshot = makeSnapshot() + const expectedScope = getCodexResetCreditScope(snapshot)! + const firstRequest = vi.fn().mockRejectedValue(new Error('connection lost')) + + await expect( + requestCodexResetCredit( + { sendRequest: firstRequest }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).rejects.toThrow('connection lost') + expect(values.size).toBe(1) + + resetCodexResetAttemptJournalForTests() + const refreshedSnapshot = makeSnapshot() + refreshedSnapshot.rateLimits.codex!.updatedAt = 101 + const refreshedScope = getCodexResetCreditScope(refreshedSnapshot)! + expect(refreshedScope.offerRevision).not.toBe(expectedScope.offerRevision) + const createRetryKey = vi.fn(() => '22222222-2222-4222-8222-222222222222') + const retry = vi.fn().mockResolvedValue({ + id: 'request-2', + ok: true, + result: { outcome: 'alreadyRedeemed', scope: expectedScope, snapshot: refreshedSnapshot }, + _meta: { runtimeId: 'runtime-1' } + }) + const result = await requestCodexResetCredit( + { sendRequest: retry }, + { hostId: 'host-a', expectedScope: refreshedScope, createIdempotencyKey: createRetryKey } + ) + + expect(result.scope).toEqual(expectedScope) + expect(createRetryKey).not.toHaveBeenCalled() + expect(retry).toHaveBeenCalledWith( + 'accounts.consumeCodexResetCredit', + { idempotencyKey: UUID, expectedScope }, + { timeoutMs: 90_000 } + ) + }) + + it('discards a definite stale-offer attempt and creates a new key only after another confirmation', async () => { + const originalSnapshot = makeSnapshot() + const originalScope = getCodexResetCreditScope(originalSnapshot)! + const refreshedSnapshot = makeSnapshot() + refreshedSnapshot.rateLimits.codex!.updatedAt = 101 + const refreshedScope = getCodexResetCreditScope(refreshedSnapshot)! + const staleResponse = vi.fn().mockResolvedValue({ + id: 'request-stale', + ok: true, + result: { + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope: originalScope, + snapshot: refreshedSnapshot + }, + _meta: { runtimeId: 'runtime-1' } + }) + + await expect( + requestCodexResetCredit( + { sendRequest: staleResponse }, + { hostId: 'host-a', expectedScope: originalScope, createIdempotencyKey: () => UUID } + ) + ).resolves.toMatchObject({ + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope: originalScope, + snapshot: refreshedSnapshot, + attemptJournalRetained: false + }) + expect(values.size).toBe(0) + + const nextKey = '22222222-2222-4222-8222-222222222222' + const createNextKey = vi.fn(() => nextKey) + const acceptedResponse = vi.fn().mockResolvedValue({ + id: 'request-next', + ok: true, + result: { outcome: 'reset', scope: refreshedScope, snapshot: refreshedSnapshot }, + _meta: { runtimeId: 'runtime-1' } + }) + await requestCodexResetCredit( + { sendRequest: acceptedResponse }, + { hostId: 'host-a', expectedScope: refreshedScope, createIdempotencyKey: createNextKey } + ) + + expect(createNextKey).toHaveBeenCalledOnce() + expect(acceptedResponse).toHaveBeenCalledWith( + 'accounts.consumeCodexResetCredit', + { idempotencyKey: nextKey, expectedScope: refreshedScope }, + { timeoutMs: 90_000 } + ) + }) + + it('singleflights concurrent requests across offer refreshes in the same account scope', async () => { + const snapshot = makeSnapshot() + const expectedScope = getCodexResetCreditScope(snapshot)! + const refreshedSnapshot = makeSnapshot() + refreshedSnapshot.rateLimits.codex!.updatedAt = 101 + const refreshedScope = getCodexResetCreditScope(refreshedSnapshot)! + let releaseRequest!: () => void + const requestGate = new Promise((resolve) => { + releaseRequest = resolve + }) + const sendRequest = vi.fn().mockImplementation(async () => { + await requestGate + return { + id: 'request-1', + ok: true, + result: { outcome: 'reset', scope: expectedScope, snapshot }, + _meta: { runtimeId: 'runtime-1' } + } + }) + const createSecondKey = vi.fn(() => '22222222-2222-4222-8222-222222222222') + + const first = requestCodexResetCredit( + { sendRequest }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1)) + const second = requestCodexResetCredit( + { sendRequest }, + { hostId: 'host-a', expectedScope: refreshedScope, createIdempotencyKey: createSecondKey } + ) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(createSecondKey).not.toHaveBeenCalled() + + releaseRequest() + const [firstResult, secondResult] = await Promise.all([first, second]) + expect(secondResult).toEqual(firstResult) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('rejects a mismatched scope or malformed nested snapshot without clearing', async () => { + const snapshot = makeSnapshot() + const expectedScope = getCodexResetCreditScope(snapshot)! + const mismatchedScope = { ...expectedScope, accountId: 'other-account' } + const mismatch = vi.fn().mockResolvedValue({ + id: 'request-1', + ok: true, + result: { outcome: 'reset', scope: mismatchedScope, snapshot }, + _meta: { runtimeId: 'runtime-1' } + }) + await expect( + requestCodexResetCredit( + { sendRequest: mismatch }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).rejects.toThrow('Invalid reset response from host') + expect(values.size).toBe(1) + + const malformed = vi.fn().mockResolvedValue({ + id: 'request-2', + ok: true, + result: { + outcome: 'reset', + scope: expectedScope, + snapshot: { ...snapshot, codex: { ...snapshot.codex, accounts: {} } } + }, + _meta: { runtimeId: 'runtime-1' } + }) + await expect( + requestCodexResetCredit( + { sendRequest: malformed }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).rejects.toThrow('Invalid accounts snapshot from host') + expect(values.size).toBe(1) + }) + + it('does not clear the journal for a mismatched definite-rejection response', async () => { + const snapshot = makeSnapshot() + const expectedScope = getCodexResetCreditScope(snapshot)! + const mismatch = vi.fn().mockResolvedValue({ + id: 'request-mismatch', + ok: true, + result: { + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope: { ...expectedScope, offerRevision: 'v1:wrong' }, + snapshot + }, + _meta: { runtimeId: 'runtime-1' } + }) + + await expect( + requestCodexResetCredit( + { sendRequest: mismatch }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).rejects.toThrow('Invalid reset response from host') + expect(values.size).toBe(1) + expect(asyncStorage.removeItem).not.toHaveBeenCalled() + }) + + it('rejects a valid snapshot that does not describe the returned redeemed scope', async () => { + const snapshot = makeSnapshot() + const expectedScope = getCodexResetCreditScope(snapshot)! + const wrongAccountSnapshot = makeSnapshot({ activeHostId: null }) + const sendRequest = vi.fn().mockResolvedValue({ + id: 'request-1', + ok: true, + result: { outcome: 'reset', scope: expectedScope, snapshot: wrongAccountSnapshot }, + _meta: { runtimeId: 'runtime-1' } + }) + + await expect( + requestCodexResetCredit( + { sendRequest }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).rejects.toThrow('Invalid reset response from host') + expect(values.size).toBe(1) + }) + + it('returns an authoritative result while reporting a failed journal cleanup', async () => { + const snapshot = makeSnapshot() + const expectedScope = getCodexResetCreditScope(snapshot)! + const sendRequest = vi.fn().mockResolvedValue({ + id: 'request-1', + ok: true, + result: { outcome: 'reset', scope: expectedScope, snapshot }, + _meta: { runtimeId: 'runtime-1' } + }) + asyncStorage.removeItem.mockRejectedValueOnce(new Error('storage unavailable')) + + await expect( + requestCodexResetCredit( + { sendRequest }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).resolves.toMatchObject({ outcome: 'reset', attemptJournalRetained: true }) + expect(values.size).toBe(1) + }) + + it('retains and safely replays a definite rejection when journal cleanup fails', async () => { + const originalSnapshot = makeSnapshot() + const originalScope = getCodexResetCreditScope(originalSnapshot)! + const refreshedSnapshot = makeSnapshot() + refreshedSnapshot.rateLimits.codex!.updatedAt = 101 + const refreshedScope = getCodexResetCreditScope(refreshedSnapshot)! + const rejectionResult = { + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope: originalScope, + snapshot: refreshedSnapshot + } + const firstResponse = vi.fn().mockResolvedValue({ + id: 'request-1', + ok: true, + result: rejectionResult, + _meta: { runtimeId: 'runtime-1' } + }) + asyncStorage.removeItem.mockRejectedValueOnce(new Error('storage unavailable')) + + await expect( + requestCodexResetCredit( + { sendRequest: firstResponse }, + { hostId: 'host-a', expectedScope: originalScope, createIdempotencyKey: () => UUID } + ) + ).resolves.toMatchObject({ + status: 'rejectedBeforeProvider', + attemptJournalRetained: true + }) + expect(values.size).toBe(1) + + const createRetryKey = vi.fn(() => '22222222-2222-4222-8222-222222222222') + const retryResponse = vi.fn().mockResolvedValue({ + id: 'request-2', + ok: true, + result: rejectionResult, + _meta: { runtimeId: 'runtime-1' } + }) + await expect( + requestCodexResetCredit( + { sendRequest: retryResponse }, + { + hostId: 'host-a', + expectedScope: refreshedScope, + createIdempotencyKey: createRetryKey + } + ) + ).resolves.toMatchObject({ + status: 'rejectedBeforeProvider', + attemptJournalRetained: false + }) + expect(createRetryKey).not.toHaveBeenCalled() + expect(retryResponse).toHaveBeenCalledWith( + 'accounts.consumeCodexResetCredit', + { idempotencyKey: UUID, expectedScope: originalScope }, + { timeoutMs: 90_000 } + ) + expect(values.size).toBe(0) + }) + + it('fails closed before RPC when the journal cannot be read or written', async () => { + const snapshot = makeSnapshot() + const expectedScope = getCodexResetCreditScope(snapshot)! + const sendRequest = vi.fn() + asyncStorage.getItem.mockRejectedValueOnce(new Error('storage unavailable')) + await expect( + requestCodexResetCredit( + { sendRequest }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).rejects.toThrow('storage unavailable') + + asyncStorage.setItem.mockRejectedValueOnce(new Error('disk full')) + await expect( + requestCodexResetCredit( + { sendRequest }, + { hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID } + ) + ).rejects.toThrow('disk full') + expect(sendRequest).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/components/codex-reset-credit.ts b/mobile/src/components/codex-reset-credit.ts new file mode 100644 index 00000000000..25ecdcb9eb0 --- /dev/null +++ b/mobile/src/components/codex-reset-credit.ts @@ -0,0 +1,282 @@ +import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format' +import { + buildCodexResetCreditExpectedScope, + type CodexResetCreditExpectedScope +} from '../../../src/shared/codex-reset-credit-scope' +import type { RpcClient } from '../transport/rpc-client' +import { + clearCodexResetAttemptAfterAuthoritativeResponse, + CodexResetCreditExpectedScopeSchema, + getCodexResetAttemptIdentityKey, + getOrCreateCodexResetAttempt +} from '../storage/codex-reset-attempt-journal' +import { + decodeAccountsSnapshot, + type AccountsSnapshot, + type ProviderRateLimits +} from './accounts-snapshot' + +export type CodexResetCreditOutcome = 'reset' | 'nothingToReset' | 'noCredit' | 'alreadyRedeemed' + +export type CodexResetCreditRejectedBeforeProviderReason = + | 'targetChanged' + | 'accountChanged' + | 'accountRevisionChanged' + | 'accountRuntimeChanged' + | 'offerUnavailable' + | 'offerChanged' + +export type CodexResetCreditConsumedRpcResult = { + outcome: CodexResetCreditOutcome + scope: CodexResetCreditExpectedScope + snapshot: AccountsSnapshot +} + +export type CodexResetCreditRejectedRpcResult = { + status: 'rejectedBeforeProvider' + retryDisposition: 'discardAttempt' + reason: CodexResetCreditRejectedBeforeProviderReason + scope: CodexResetCreditExpectedScope + snapshot: AccountsSnapshot +} + +export type CodexResetCreditRpcResult = + | CodexResetCreditConsumedRpcResult + | CodexResetCreditRejectedRpcResult + +export type CodexResetCreditRequestResult = CodexResetCreditRpcResult & { + // A valid host result remains authoritative even if local cleanup fails. + // The retained UUID makes a later retry idempotent instead of hiding success. + attemptJournalRetained: boolean +} + +export type CodexResetCreditSummary = { + availableCount: number + availabilityLabel: string + expiryLabel: string | null +} + +const RESET_RPC_TIMEOUT_MS = 90_000 +const resetRequests = new Map>() + +export function getCodexResetCreditSummary( + limits: ProviderRateLimits | null, + now: number +): CodexResetCreditSummary | null { + const credits = limits?.rateLimitResetCredits + const count = credits?.availableCount ?? 0 + if (!Number.isInteger(count) || count <= 0) { + return null + } + const expiry = credits?.nextExpiresAt + const expiryLabel = + typeof expiry === 'number' && Number.isFinite(expiry) + ? formatResetCountdown(expiry - now).replace( + /^Resets/, + count === 1 ? 'Expires' : 'Next expires' + ) + : null + return { + availableCount: count, + availabilityLabel: `${count} ${count === 1 ? 'reset' : 'resets'} available`, + expiryLabel + } +} + +export function getCodexResetCreditOutcomeCopy(outcome: CodexResetCreditOutcome): { + title: string + message: string +} { + switch (outcome) { + case 'reset': + return { title: 'Rate limits reset', message: 'Codex usage has been refreshed.' } + case 'alreadyRedeemed': + return { title: 'Reset already applied', message: 'Codex usage has been refreshed.' } + case 'nothingToReset': + return { + title: 'Nothing to reset', + message: 'No eligible Codex rate-limit window is exhausted.' + } + case 'noCredit': + return { + title: 'No reset available', + message: 'This account has no earned reset credits available.' + } + } +} + +export function getActiveCodexAccountIdForRateLimitTarget( + snapshot: AccountsSnapshot +): string | null { + const target = snapshot.rateLimits.codexTarget + const selection = snapshot.codex.activeAccountIdsByRuntime + if (!selection) { + return null + } + if (target.runtime === 'host') { + return target.wslDistro === null ? selection.host : null + } + const distro = target.wslDistro?.trim() + return distro ? (selection.wsl[distro] ?? null) : null +} + +export function getCodexResetCreditScope( + snapshot: AccountsSnapshot +): CodexResetCreditExpectedScope | null { + const activeAccountId = getActiveCodexAccountIdForRateLimitTarget(snapshot) + const account = activeAccountId + ? (snapshot.codex.accounts.find((candidate) => candidate.id === activeAccountId) ?? null) + : null + const scope = buildCodexResetCreditExpectedScope({ + target: snapshot.rateLimits.codexTarget, + account, + limits: snapshot.rateLimits.codex + }) + if (!scope) { + return null + } + const parsed = CodexResetCreditExpectedScopeSchema.safeParse(scope) + return parsed.success ? parsed.data : null +} + +function scopesEqual( + left: CodexResetCreditExpectedScope, + right: CodexResetCreditExpectedScope +): boolean { + return ( + left.target.runtime === right.target.runtime && + left.target.wslDistro === right.target.wslDistro && + left.accountId === right.accountId && + left.accountRevision === right.accountRevision && + left.offerRevision === right.offerRevision + ) +} + +function decodeResetResult( + value: unknown, + expectedScope: CodexResetCreditExpectedScope +): CodexResetCreditRpcResult { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid reset response from host') + } + const result = value as Record + const scope = CodexResetCreditExpectedScopeSchema.safeParse(result.scope) + if (!scope.success || !scopesEqual(scope.data, expectedScope)) { + throw new Error('Invalid reset response from host') + } + const snapshot = decodeAccountsSnapshot(result.snapshot) + if (result.status === 'rejectedBeforeProvider') { + const reason = result.reason + if ( + result.retryDisposition !== 'discardAttempt' || + result.outcome !== undefined || + (reason !== 'targetChanged' && + reason !== 'accountChanged' && + reason !== 'accountRevisionChanged' && + reason !== 'accountRuntimeChanged' && + reason !== 'offerUnavailable' && + reason !== 'offerChanged') + ) { + throw new Error('Invalid reset response from host') + } + return { + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason, + scope: scope.data, + snapshot + } + } + const outcome = result.outcome + if ( + result.status !== undefined || + outcome === undefined || + (outcome !== 'reset' && + outcome !== 'nothingToReset' && + outcome !== 'noCredit' && + outcome !== 'alreadyRedeemed') + ) { + throw new Error('Invalid reset response from host') + } + const snapshotAccount = snapshot.codex.accounts.find( + (account) => account.id === scope.data.accountId + ) + if ( + snapshot.rateLimits.codexTarget.runtime !== scope.data.target.runtime || + snapshot.rateLimits.codexTarget.wslDistro !== scope.data.target.wslDistro || + getActiveCodexAccountIdForRateLimitTarget(snapshot) !== scope.data.accountId || + snapshotAccount?.updatedAt !== scope.data.accountRevision + ) { + throw new Error('Invalid reset response from host') + } + return { + outcome, + scope: scope.data, + snapshot + } +} + +async function performCodexResetCreditRequest( + client: Pick, + options: { + hostId: string + expectedScope: CodexResetCreditExpectedScope + createIdempotencyKey: () => string + } +): Promise { + const attempt = await getOrCreateCodexResetAttempt(options) + const response = await client.sendRequest( + 'accounts.consumeCodexResetCredit', + { + idempotencyKey: attempt.idempotencyKey, + expectedScope: attempt.expectedScope + }, + { timeoutMs: RESET_RPC_TIMEOUT_MS } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = decodeResetResult(response.result, attempt.expectedScope) + let attemptJournalRetained = false + try { + await clearCodexResetAttemptAfterAuthoritativeResponse({ + hostId: options.hostId, + expectedScope: attempt.expectedScope, + idempotencyKey: attempt.idempotencyKey + }) + } catch { + attemptJournalRetained = true + } + return { ...result, attemptJournalRetained } +} + +export async function requestCodexResetCredit( + client: Pick, + options: { + hostId: string + expectedScope: CodexResetCreditExpectedScope + createIdempotencyKey: () => string + } +): Promise { + const requestKey = getCodexResetAttemptIdentityKey(options) + const existing = resetRequests.get(requestKey) + if (existing) { + return existing + } + // Why: two mounted views can confirm the same offer concurrently. Share the + // whole attempt so one authoritative response cannot clear the other's retry key. + const operation = performCodexResetCreditRequest(client, options) + resetRequests.set(requestKey, operation) + try { + return await operation + } finally { + if (resetRequests.get(requestKey) === operation) { + resetRequests.delete(requestKey) + } + } +} + +/** Test-only: clear request singleflight state between cases. */ +export function resetCodexResetCreditRequestsForTests(): void { + resetRequests.clear() +} diff --git a/mobile/src/components/use-codex-reset-credit-action.ts b/mobile/src/components/use-codex-reset-credit-action.ts new file mode 100644 index 00000000000..4e1b89cd51f --- /dev/null +++ b/mobile/src/components/use-codex-reset-credit-action.ts @@ -0,0 +1,115 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { Alert } from 'react-native' +import * as ExpoCrypto from 'expo-crypto' +import type { CodexResetCreditExpectedScope } from '../../../src/shared/codex-reset-credit-scope' +import type { RpcClient } from '../transport/rpc-client' +import type { AccountsSnapshot } from './account-usage-state' +import { + getCodexResetCreditOutcomeCopy, + getCodexResetCreditScope, + requestCodexResetCredit +} from './codex-reset-credit' +import { useCodexResetCreditCapability } from './codex-reset-credit-capability' + +function describeScope(snapshot: AccountsSnapshot, scope: CodexResetCreditExpectedScope): string { + const account = snapshot.codex.accounts.find((candidate) => candidate.id === scope.accountId) + const identity = account?.email ?? 'the selected managed account' + if (scope.target.runtime === 'host') { + return `${identity} on the host` + } + return `${identity} on WSL ${scope.target.wslDistro}` +} + +export function useCodexResetCreditAction({ + client, + connected, + hostId, + snapshot, + accountMutationBusy, + onSnapshot +}: { + client: RpcClient | null + connected: boolean + hostId: string | undefined + snapshot: AccountsSnapshot | null + accountMutationBusy: boolean + onSnapshot: (snapshot: AccountsSnapshot) => void +}): { + supported: boolean + resetting: boolean + resetScope: CodexResetCreditExpectedScope | null + scopeLabel: string | null + confirmReset: () => void +} { + const supported = useCodexResetCreditCapability(client, connected) + const [resetting, setResetting] = useState(false) + const inFlightRef = useRef(false) + const resetScope = useMemo( + () => (snapshot ? getCodexResetCreditScope(snapshot) : null), + [snapshot] + ) + const scopeLabel = useMemo( + () => (snapshot && resetScope ? describeScope(snapshot, resetScope) : null), + [resetScope, snapshot] + ) + + const consume = useCallback( + async (expectedScope: CodexResetCreditExpectedScope) => { + if (!client || !hostId || inFlightRef.current) { + return + } + inFlightRef.current = true + setResetting(true) + try { + const result = await requestCodexResetCredit(client, { + hostId, + expectedScope, + createIdempotencyKey: () => ExpoCrypto.randomUUID() + }) + onSnapshot(result.snapshot) + if ('status' in result) { + const cleanupWarning = result.attemptJournalRetained + ? '\n\nThis phone could not clear the discarded retry record. Retrying it is safe, but the record must be cleared before a new reset can be confirmed for this account.' + : '' + Alert.alert( + 'Reset details changed', + `The account or reset offer changed before the host contacted Codex. Review the updated details, then confirm again.${cleanupWarning}` + ) + return + } + const copy = getCodexResetCreditOutcomeCopy(result.outcome) + const cleanupWarning = result.attemptJournalRetained + ? '\n\nThe host confirmed this attempt, but this phone could not clear its retry record. A later retry will reuse the same safe operation ID.' + : '' + Alert.alert(copy.title, `${copy.message}${cleanupWarning}`) + } catch (error) { + Alert.alert( + 'Could not reset rate limits', + error instanceof Error ? error.message : String(error) + ) + } finally { + inFlightRef.current = false + setResetting(false) + } + }, + [client, hostId, onSnapshot] + ) + + const confirmReset = useCallback(() => { + if (!supported || !connected || accountMutationBusy || resetting || !resetScope || !snapshot) { + return + } + const confirmedScope = resetScope + const confirmedLabel = describeScope(snapshot, confirmedScope) + Alert.alert( + 'Use a rate-limit reset?', + `This spends one earned reset for ${confirmedLabel} and immediately resets eligible rate-limit windows.`, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Use reset', onPress: () => void consume(confirmedScope) } + ] + ) + }, [accountMutationBusy, connected, consume, resetScope, resetting, snapshot, supported]) + + return { supported, resetting, resetScope, scopeLabel, confirmReset } +} diff --git a/mobile/src/mock-server-account-state.test.ts b/mobile/src/mock-server-account-state.test.ts new file mode 100644 index 00000000000..2f5ae3ea377 --- /dev/null +++ b/mobile/src/mock-server-account-state.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + consumeMockCodexResetCredit, + createMockAccountsSnapshot, + getMockCodexResetScope, + resetMockAccountState, + selectMockCodexAccount +} from '../scripts/mock-server-account-state' + +const FIRST_OPERATION_ID = '11111111-1111-4111-8111-111111111111' + +describe('mock account reset state', () => { + beforeEach(() => { + resetMockAccountState(1_700_000_000_000) + }) + + it('keeps reset and expiry deadlines fixed between snapshots', () => { + const first = createMockAccountsSnapshot() + const second = createMockAccountsSnapshot() + + expect(second.rateLimits.codex.session?.resetsAt).toBe(first.rateLimits.codex.session?.resetsAt) + expect(second.rateLimits.codex.rateLimitResetCredits.nextExpiresAt).toBe( + first.rateLimits.codex.rateLimitResetCredits.nextExpiresAt + ) + }) + + it('resets only the selected account and updates its visible usage', () => { + const personalScope = getMockCodexResetScope() + expect(personalScope).not.toBeNull() + + expect(consumeMockCodexResetCredit(FIRST_OPERATION_ID, personalScope)).toMatchObject({ + outcome: 'reset', + scope: personalScope + }) + const personalAfter = createMockAccountsSnapshot() + expect(personalAfter.rateLimits.codex.session?.usedPercent).toBe(0) + expect(personalAfter.rateLimits.codex.rateLimitResetCredits.availableCount).toBe(0) + + selectMockCodexAccount('codex-team') + const team = createMockAccountsSnapshot() + expect(team.rateLimits.codex.session?.usedPercent).toBe(100) + expect(team.rateLimits.codex.rateLimitResetCredits.availableCount).toBe(1) + expect(getMockCodexResetScope()?.accountId).toBe('codex-team') + }) + + it('replays the same operation result and authoritatively discards a stale attempt', () => { + const scope = getMockCodexResetScope() + expect(scope).not.toBeNull() + const first = consumeMockCodexResetCredit(FIRST_OPERATION_ID, scope) + expect(consumeMockCodexResetCredit(FIRST_OPERATION_ID, scope)).toEqual(first) + + expect(() => consumeMockCodexResetCredit('not-a-uuid', scope)).toThrow('Invalid idempotencyKey') + expect(consumeMockCodexResetCredit('22222222-2222-4222-8222-222222222222', scope)).toEqual({ + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope + }) + }) +}) diff --git a/mobile/src/mock-server-terminal-fixture-routing.test.ts b/mobile/src/mock-server-terminal-fixture-routing.test.ts new file mode 100644 index 00000000000..f5c5d45c304 --- /dev/null +++ b/mobile/src/mock-server-terminal-fixture-routing.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { WebSocket } from 'ws' +import { + handleRequest, + type RpcRequest, + type RpcResponse +} from '../scripts/mock-server-rpc-handlers' + +let requestSequence = 0 + +function sendMockRequest(method: string, params?: Record): RpcResponse { + let response: RpcResponse | undefined + const request: RpcRequest = { id: `request-${++requestSequence}`, method, params } + handleRequest( + request, + (nextResponse) => { + response = nextResponse + }, + {} as WebSocket + ) + expect(response).toBeDefined() + return response! +} + +function listedTerminalWorktreeIds(worktree?: string): string[] { + const response = sendMockRequest('terminal.list', worktree ? { worktree } : undefined) + const result = response.result as { terminals: Array<{ worktreeId: string }> } + return [...new Set(result.terminals.map((terminal) => terminal.worktreeId))] +} + +describe('mock server terminal fixture routing', () => { + it('follows worktree creation and activation', () => { + const worktreeResponse = sendMockRequest('worktree.ps') + const initialWorktreeId = ( + worktreeResponse.result as { worktrees: Array<{ worktreeId: string }> } + ).worktrees[0]!.worktreeId + + const createResponse = sendMockRequest('worktree.create', { + repo: 'id:repo-1', + name: 'terminal-fixture-routing' + }) + const createdWorktreeId = (createResponse.result as { worktree: { id: string } }).worktree.id + expect(listedTerminalWorktreeIds()).toEqual([createdWorktreeId]) + expect(listedTerminalWorktreeIds(`id:${initialWorktreeId}`)).toEqual([initialWorktreeId]) + + sendMockRequest('worktree.activate', { worktree: `id:${initialWorktreeId}` }) + expect(listedTerminalWorktreeIds()).toEqual([initialWorktreeId]) + }) +}) diff --git a/mobile/src/storage/codex-reset-attempt-journal.test.ts b/mobile/src/storage/codex-reset-attempt-journal.test.ts new file mode 100644 index 00000000000..f4708961f97 --- /dev/null +++ b/mobile/src/storage/codex-reset-attempt-journal.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CodexResetCreditExpectedScope } from '../../../src/shared/codex-reset-credit-scope' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +import { + clearCodexResetAttemptAfterAuthoritativeResponse, + getOrCreateCodexResetAttempt, + resetCodexResetAttemptJournalForTests +} from './codex-reset-attempt-journal' + +const FIRST_UUID = '11111111-1111-4111-8111-111111111111' +const SECOND_UUID = '22222222-2222-4222-8222-222222222222' + +function makeScope( + overrides: Partial = {} +): CodexResetCreditExpectedScope { + return { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account-a', + accountRevision: 10, + offerRevision: 'v1:offer-a', + ...overrides + } +} + +describe('Codex reset attempt journal', () => { + let values: Map + + beforeEach(() => { + vi.clearAllMocks() + resetCodexResetAttemptJournalForTests() + values = new Map() + asyncStorage.getItem.mockImplementation(async (key: string) => values.get(key) ?? null) + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + values.set(key, value) + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + values.delete(key) + }) + }) + + it('persists an unresolved UUID and reuses it after a module-level remount', async () => { + const identity = { hostId: 'host-a', expectedScope: makeScope() } + const createFirst = vi.fn(() => FIRST_UUID) + const first = await getOrCreateCodexResetAttempt({ + ...identity, + createIdempotencyKey: createFirst + }) + + resetCodexResetAttemptJournalForTests() + const createAfterRemount = vi.fn(() => SECOND_UUID) + const restored = await getOrCreateCodexResetAttempt({ + ...identity, + createIdempotencyKey: createAfterRemount + }) + + expect(restored).toEqual(first) + expect(createAfterRemount).not.toHaveBeenCalled() + expect(values.size).toBe(1) + }) + + it('isolates attempts by host and stable target/account revision scope', async () => { + const variants = [ + { hostId: 'host-a', expectedScope: makeScope() }, + { hostId: 'host-b', expectedScope: makeScope() }, + { hostId: 'host-a', expectedScope: makeScope({ accountId: 'account-b' }) }, + { hostId: 'host-a', expectedScope: makeScope({ accountRevision: 11 }) }, + { + hostId: 'host-a', + expectedScope: makeScope({ target: { runtime: 'wsl', wslDistro: 'Ubuntu' } }) + } + ] + + const attempts = await Promise.all( + variants.map((identity, index) => + getOrCreateCodexResetAttempt({ + ...identity, + createIdempotencyKey: () => + `${String(index + 1).repeat(8)}-${String(index + 1).repeat(4)}-4${String(index + 1).repeat(3)}-8${String(index + 1).repeat(3)}-${String(index + 1).repeat(12)}` + }) + ) + ) + + expect(new Set(attempts.map((attempt) => attempt.idempotencyKey)).size).toBe(variants.length) + expect(values.size).toBe(variants.length) + }) + + it('replays the original exact offer after a refresh changes its offer revision', async () => { + const originalScope = makeScope() + const original = await getOrCreateCodexResetAttempt({ + hostId: 'host-a', + expectedScope: originalScope, + createIdempotencyKey: () => FIRST_UUID + }) + + resetCodexResetAttemptJournalForTests() + const createRefreshedKey = vi.fn(() => SECOND_UUID) + const restored = await getOrCreateCodexResetAttempt({ + hostId: 'host-a', + expectedScope: makeScope({ offerRevision: 'v1:refreshed-offer' }), + createIdempotencyKey: createRefreshedKey + }) + + expect(restored).toEqual(original) + expect(restored.expectedScope).toEqual(originalScope) + expect(createRefreshedKey).not.toHaveBeenCalled() + expect(values.size).toBe(1) + }) + + it('keeps each account attempt while switching away and back', async () => { + const accountA = makeScope({ accountId: 'account-a' }) + const accountB = makeScope({ accountId: 'account-b' }) + await getOrCreateCodexResetAttempt({ + hostId: 'host-a', + expectedScope: accountA, + createIdempotencyKey: () => FIRST_UUID + }) + await getOrCreateCodexResetAttempt({ + hostId: 'host-a', + expectedScope: accountB, + createIdempotencyKey: () => SECOND_UUID + }) + + const createAfterSwitchBack = vi.fn(() => '33333333-3333-4333-8333-333333333333') + const restoredA = await getOrCreateCodexResetAttempt({ + hostId: 'host-a', + expectedScope: { ...accountA, offerRevision: 'v1:after-switch-back' }, + createIdempotencyKey: createAfterSwitchBack + }) + + expect(restoredA.idempotencyKey).toBe(FIRST_UUID) + expect(createAfterSwitchBack).not.toHaveBeenCalled() + expect(values.size).toBe(2) + }) + + it('serializes same-scope creation so concurrent callers share one durable UUID', async () => { + let releaseWrite!: () => void + const writeGate = new Promise((resolve) => { + releaseWrite = resolve + }) + asyncStorage.setItem.mockImplementationOnce(async (key: string, value: string) => { + await writeGate + values.set(key, value) + }) + const identity = { hostId: 'host-a', expectedScope: makeScope() } + const createFirst = vi.fn(() => FIRST_UUID) + const createSecond = vi.fn(() => SECOND_UUID) + + const first = getOrCreateCodexResetAttempt({ + ...identity, + createIdempotencyKey: createFirst + }) + await vi.waitFor(() => expect(asyncStorage.setItem).toHaveBeenCalledTimes(1)) + const second = getOrCreateCodexResetAttempt({ + ...identity, + expectedScope: makeScope({ offerRevision: 'v1:refreshed-offer' }), + createIdempotencyKey: createSecond + }) + await Promise.resolve() + expect(createSecond).not.toHaveBeenCalled() + + releaseWrite() + await expect(Promise.all([first, second])).resolves.toMatchObject([ + { idempotencyKey: FIRST_UUID }, + { idempotencyKey: FIRST_UUID } + ]) + expect(createSecond).not.toHaveBeenCalled() + }) + + it('fails closed on corrupt storage, read failures, write failures, and invalid UUIDs', async () => { + const identity = { hostId: 'host-a', expectedScope: makeScope() } + await getOrCreateCodexResetAttempt({ + ...identity, + createIdempotencyKey: () => FIRST_UUID + }) + const [key] = values.keys() + values.set(key!, '{not-json') + await expect( + getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: () => SECOND_UUID }) + ).rejects.toThrow(/unreadable/) + + values.clear() + asyncStorage.getItem.mockRejectedValueOnce(new Error('storage unavailable')) + await expect( + getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: () => SECOND_UUID }) + ).rejects.toThrow('storage unavailable') + + asyncStorage.setItem.mockRejectedValueOnce(new Error('disk full')) + await expect( + getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: () => SECOND_UUID }) + ).rejects.toThrow('disk full') + expect(values.size).toBe(0) + + await expect( + getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: () => 'not-a-uuid' }) + ).rejects.toThrow(/idempotency key is invalid/) + }) + + it('never replaces a pending key based on age and clears only the matching authoritative attempt', async () => { + const identity = { hostId: 'host-a', expectedScope: makeScope() } + const createKey = vi.fn(() => FIRST_UUID) + await getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: createKey }) + + const now = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2036-01-01T00:00:00Z')) + const oldAttempt = await getOrCreateCodexResetAttempt({ + ...identity, + createIdempotencyKey: () => SECOND_UUID + }) + now.mockRestore() + expect(oldAttempt.idempotencyKey).toBe(FIRST_UUID) + + await expect( + clearCodexResetAttemptAfterAuthoritativeResponse({ + ...identity, + idempotencyKey: SECOND_UUID + }) + ).rejects.toThrow(/identity changed/) + expect(values.size).toBe(1) + + await clearCodexResetAttemptAfterAuthoritativeResponse({ + ...identity, + idempotencyKey: FIRST_UUID + }) + expect(values.size).toBe(0) + }) +}) diff --git a/mobile/src/storage/codex-reset-attempt-journal.ts b/mobile/src/storage/codex-reset-attempt-journal.ts new file mode 100644 index 00000000000..c623566d129 --- /dev/null +++ b/mobile/src/storage/codex-reset-attempt-journal.ts @@ -0,0 +1,182 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { sha256 } from '@noble/hashes/sha256' +import { z } from 'zod' +import type { CodexResetCreditExpectedScope } from '../../../src/shared/codex-reset-credit-scope' + +const STORAGE_PREFIX = 'orca:codex-reset-credit-attempt:v1:' +const IdempotencyKeySchema = z.uuid() + +export const CodexResetCreditExpectedScopeSchema = z + .object({ + target: z + .object({ + runtime: z.enum(['host', 'wsl']), + wslDistro: z.string().min(1).max(255).nullable() + }) + .strict(), + accountId: z.string().min(1).max(512), + accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + offerRevision: z.string().startsWith('v1:').max(4_096) + }) + .strict() + .superRefine((scope, context) => { + if (scope.target.runtime === 'host' && scope.target.wslDistro !== null) { + context.addIssue({ + code: 'custom', + message: 'Host reset scopes cannot name a WSL distro', + path: ['target', 'wslDistro'] + }) + } + if ( + scope.target.runtime === 'wsl' && + (scope.target.wslDistro === null || scope.target.wslDistro.trim() !== scope.target.wslDistro) + ) { + context.addIssue({ + code: 'custom', + message: 'WSL reset scopes require an exact distro', + path: ['target', 'wslDistro'] + }) + } + }) + +const CodexResetAttemptSchema = z + .object({ + v: z.literal(1), + hostId: z.string().min(1), + expectedScope: CodexResetCreditExpectedScopeSchema, + idempotencyKey: IdempotencyKeySchema + }) + .strict() + +export type CodexResetAttempt = z.infer + +type AttemptIdentity = { + hostId: string + expectedScope: CodexResetCreditExpectedScope +} + +const scopeMutations = new Map>() + +// Why: a provider attempt's forced refresh changes offerRevision even when its +// response is lost. Keep one unresolved original offer per stable account scope. +function stableAccountScopePayload({ hostId, expectedScope }: AttemptIdentity): string { + return JSON.stringify([ + hostId, + expectedScope.target.runtime, + expectedScope.target.wslDistro, + expectedScope.accountId, + expectedScope.accountRevision + ]) +} + +function digestHex(value: string): string { + return Array.from(sha256(value), (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +function storageKey(identity: AttemptIdentity): string { + return `${STORAGE_PREFIX}${digestHex(stableAccountScopePayload(identity))}` +} + +export function getCodexResetAttemptIdentityKey(identity: AttemptIdentity): string { + return storageKey(identity) +} + +function stableAccountScopesEqual( + left: CodexResetCreditExpectedScope, + right: CodexResetCreditExpectedScope +): boolean { + return ( + left.target.runtime === right.target.runtime && + left.target.wslDistro === right.target.wslDistro && + left.accountId === right.accountId && + left.accountRevision === right.accountRevision + ) +} + +function parseAttempt(raw: string, identity: AttemptIdentity): CodexResetAttempt { + let value: unknown + try { + value = JSON.parse(raw) + } catch { + throw new Error('Codex reset attempt journal is unreadable') + } + const result = CodexResetAttemptSchema.safeParse(value) + if ( + !result.success || + result.data.hostId !== identity.hostId || + !stableAccountScopesEqual(result.data.expectedScope, identity.expectedScope) + ) { + throw new Error('Codex reset attempt journal is unreadable') + } + return result.data +} + +async function withScopeMutation( + identity: AttemptIdentity, + action: () => Promise +): Promise { + const key = storageKey(identity) + const previous = scopeMutations.get(key) ?? Promise.resolve() + const operation = previous.then(action, action) + const tail = operation.then( + () => undefined, + () => undefined + ) + scopeMutations.set(key, tail) + try { + return await operation + } finally { + if (scopeMutations.get(key) === tail) { + scopeMutations.delete(key) + } + } +} + +export async function getOrCreateCodexResetAttempt( + identity: AttemptIdentity & { createIdempotencyKey: () => string } +): Promise { + return withScopeMutation(identity, async () => { + const key = storageKey(identity) + const raw = await AsyncStorage.getItem(key) + if (raw !== null) { + return parseAttempt(raw, identity) + } + + const idempotencyKey = identity.createIdempotencyKey() + if (!IdempotencyKeySchema.safeParse(idempotencyKey).success) { + throw new Error('Codex reset attempt idempotency key is invalid') + } + const attempt = CodexResetAttemptSchema.parse({ + v: 1, + hostId: identity.hostId, + expectedScope: identity.expectedScope, + idempotencyKey + }) + // Why: the key must survive a committed provider mutation whose response is + // lost; no reset RPC may start until this write has completed successfully. + await AsyncStorage.setItem(key, JSON.stringify(attempt)) + return attempt + }) +} + +export async function clearCodexResetAttemptAfterAuthoritativeResponse( + identity: AttemptIdentity & { idempotencyKey: string } +): Promise { + return withScopeMutation(identity, async () => { + const key = storageKey(identity) + const raw = await AsyncStorage.getItem(key) + if (raw === null) { + return + } + const current = parseAttempt(raw, identity) + if (current.idempotencyKey !== identity.idempotencyKey) { + throw new Error('Codex reset attempt journal identity changed') + } + await AsyncStorage.removeItem(key) + }) +} + +/** Test-only: drain in-memory queues while preserving the durable storage mock. */ +export function resetCodexResetAttemptJournalForTests(): void { + scopeMutations.clear() +} diff --git a/mobile/src/transport/runtime-capability-probe.test.ts b/mobile/src/transport/runtime-capability-probe.test.ts index cffc97f30d1..2272c25610f 100644 --- a/mobile/src/transport/runtime-capability-probe.test.ts +++ b/mobile/src/transport/runtime-capability-probe.test.ts @@ -48,6 +48,54 @@ describe('startRuntimeCapabilityProbe', () => { cancel() }) + it('treats malformed capabilities as unsupported', async () => { + const response: RpcResponse = { + ok: true, + id: '1', + result: { capabilities: 'a.v1' }, + _meta: { runtimeId: 'r1' } + } + const { client, calls } = makeClient([response]) + const seen: (readonly string[])[] = [] + const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities)) + await flushMicrotasks() + expect(seen).toEqual([[]]) + expect(calls()).toBe(1) + cancel() + }) + + it('rejects capability arrays containing non-string values', async () => { + const response: RpcResponse = { + ok: true, + id: '1', + result: { capabilities: ['a.v1', 42] }, + _meta: { runtimeId: 'r1' } + } + const { client, calls } = makeClient([response]) + const seen: (readonly string[])[] = [] + const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities)) + await flushMicrotasks() + expect(seen).toEqual([[]]) + expect(calls()).toBe(1) + cancel() + }) + + it('treats a malformed status result as unsupported', async () => { + const response: RpcResponse = { + ok: true, + id: '1', + result: null, + _meta: { runtimeId: 'r1' } + } + const { client, calls } = makeClient([response]) + const seen: (readonly string[])[] = [] + const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities)) + await flushMicrotasks() + expect(seen).toEqual([[]]) + expect(calls()).toBe(1) + cancel() + }) + it('retries promptly after a logical-client cutover rejection', async () => { const { client, calls } = makeClient([new LogicalClientCutoverError(), ok(['a.v1'])]) const seen: (readonly string[])[] = [] diff --git a/mobile/src/transport/runtime-capability-probe.ts b/mobile/src/transport/runtime-capability-probe.ts index b0f4ad1150d..ef636552863 100644 --- a/mobile/src/transport/runtime-capability-probe.ts +++ b/mobile/src/transport/runtime-capability-probe.ts @@ -27,8 +27,17 @@ export function startRuntimeCapabilityProbe( scheduleRetry(false) return } - const status = (response as RpcSuccess).result as { capabilities?: string[] } - onCapabilities(status.capabilities ?? []) + const result = (response as RpcSuccess).result + const rawCapabilities = + result && typeof result === 'object' + ? (result as { capabilities?: unknown }).capabilities + : null + const capabilities = + Array.isArray(rawCapabilities) && + rawCapabilities.every((value) => typeof value === 'string') + ? rawCapabilities + : [] + onCapabilities(capabilities) }, (error: unknown) => { if (cancelled) { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 435a187fe4a..cb6ba306f2a 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -15,6 +15,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { CodexRateLimitAccountsState, GlobalSettings } from '../../shared/types' +import type { ProviderRateLimits, RateLimitState } from '../../shared/rate-limit-types' +import { buildCodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' +import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger' import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust' @@ -186,6 +189,7 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { } function createStore(settings: GlobalSettings) { + let resetLedger: CodexResetCreditAttemptLedger = { version: 1, attempts: [] } return { getSettings: vi.fn(() => settings), updateSettings: vi.fn((updates: Partial) => { @@ -198,6 +202,10 @@ function createStore(settings: GlobalSettings) { } } return settings + }), + getCodexResetCreditAttemptLedger: vi.fn(() => structuredClone(resetLedger)), + replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn((next: CodexResetCreditAttemptLedger) => { + resetLedger = structuredClone(next) }) } } @@ -212,7 +220,52 @@ function createRateLimits() { function createRuntimeHome() { return { syncForCurrentSelection: vi.fn(), - clearLastWrittenAuthJson: vi.fn() + clearLastWrittenAuthJson: vi.fn(), + prepareForRateLimitFetch: vi.fn(() => null) + } +} + +function createResetCreditLimits(updatedAt = 30): ProviderRateLimits { + return { + provider: 'codex', + session: { + usedPercent: 100, + windowMinutes: 300, + resetsAt: 1_000, + resetDescription: 'soon' + }, + weekly: null, + rateLimitResetCredits: { + availableCount: 1, + totalEarnedCount: 1, + nextExpiresAt: 2_000, + credits: [{ status: 'available', expiresAt: 2_000, grantedAt: 500 }] + }, + updatedAt, + error: null, + status: 'ok' + } +} + +function createResetRateLimitState( + codex: ProviderRateLimits, + target: RateLimitState['codexTarget'] = { runtime: 'host', wslDistro: null } +): RateLimitState { + return { + claude: null, + codex, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null, + minimaxCookieConfigured: false, + grokAuthConfigured: false, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: target, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] } } @@ -2096,6 +2149,1038 @@ describe('CodexAccountService config sync', () => { expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(2) }) + it('validates a reset only after an earlier account switch leaves the mutation queue', async () => { + const firstHome = createManagedHome(testState.userDataDir, 'account-1') + const secondHome = createManagedHome(testState.userDataDir, 'account-2') + const firstAccount = { + id: 'account-1', + email: 'first@example.com', + managedHomePath: firstHome, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [ + firstAccount, + { + ...firstAccount, + id: 'account-2', + email: 'second@example.com', + managedHomePath: secondHome, + updatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + let finishRefresh: (() => void) | undefined + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: vi.fn(), + refreshForCodexAccountChange: vi.fn( + () => + new Promise((resolve) => { + finishRefresh = resolve + }) + ) + } + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account: firstAccount, + limits + })! + + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + rateLimits as never, + createRuntimeHome() as never + ) + const selecting = service.selectAccount('account-2') + await vi.waitFor(() => expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledOnce()) + const resetting = service.consumeRateLimitResetCredit( + '11111111-1111-4111-8111-111111111111', + expectedScope + ) + finishRefresh?.() + + await selecting + await expect(resetting).resolves.toMatchObject({ + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'accountChanged', + scope: expectedScope + }) + expect(rateLimits.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled() + }) + + it('singleflights concurrent same-key reset attempts and forwards the approved home and target', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const nextManagedHomePath = createManagedHome(testState.userDataDir, 'account-2') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const nextAccount = { + ...account, + id: 'account-2', + email: 'next@example.com', + managedHomePath: nextManagedHomePath, + updatedAt: 2 + } + const settings = createSettings({ + codexManagedAccounts: [account, nextAccount], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + let finishConsume: ((value: { outcome: 'reset'; state: RateLimitState }) => void) | undefined + const consume = vi.fn( + () => + new Promise<{ outcome: 'reset'; state: RateLimitState }>((resolve) => { + finishConsume = resolve + }) + ) + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(settings) as never, + rateLimits as never, + createRuntimeHome() as never + ) + const idempotencyKey = '22222222-2222-4222-8222-222222222222' + + const first = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) + const second = service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) + expect(second).toBe(first) + await vi.waitFor(() => expect(consume).toHaveBeenCalledOnce()) + const selectingNextAccount = service.selectAccount(nextAccount.id) + finishConsume?.({ outcome: 'reset', state }) + + const resetResults = await Promise.all([first, second]) + expect(resetResults).toMatchObject([ + { outcome: 'reset', scope: expectedScope }, + { outcome: 'reset', scope: expectedScope } + ]) + await selectingNextAccount + expect(resetResults[0]?.codex.activeAccountId).toBe(account.id) + expect(resetResults[0]?.rateLimits).toBe(state) + expect(service.listAccounts().activeAccountId).toBe(nextAccount.id) + expect(consume).toHaveBeenCalledWith({ + idempotencyKey, + target: { runtime: 'host', wslDistro: null }, + codexHomePath: managedHomePath + }) + await expect( + service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) + ).rejects.toThrow('selected Codex account changed') + expect(consume).toHaveBeenCalledOnce() + + await service.selectAccount(account.id) + const settledReplay = await service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) + expect(settledReplay).toMatchObject({ + outcome: 'reset', + scope: expectedScope, + codex: { activeAccountId: account.id } + }) + expect(consume).toHaveBeenCalledOnce() + await expect( + service.consumeRateLimitResetCredit('77777777-7777-4777-8777-777777777777', expectedScope) + ).rejects.toThrow('already attempted') + await expect( + service.consumeRateLimitResetCredit(idempotencyKey, { + ...expectedScope, + offerRevision: 'v1:different' + }) + ).rejects.toThrow('different reset scope') + }) + + it('blocks a different key after an ambiguous provider error but lets desktop retry', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const consume = vi + .fn() + .mockRejectedValueOnce(new Error('provider response lost')) + .mockResolvedValueOnce({ outcome: 'alreadyRedeemed', state }) + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(settings) as never, + rateLimits as never, + createRuntimeHome() as never + ) + const firstKey = '33333333-3333-4333-8333-333333333333' + + await expect(service.consumeRateLimitResetCredit(firstKey, expectedScope)).rejects.toThrow( + 'provider response lost' + ) + await expect(service.consumeCurrentRateLimitResetCredit()).resolves.toMatchObject({ + outcome: 'alreadyRedeemed', + state + }) + expect(consume).toHaveBeenCalledTimes(2) + await expect( + service.consumeRateLimitResetCredit('44444444-4444-4444-8444-444444444444', expectedScope) + ).rejects.toThrow('already attempted') + await expect( + service.consumeRateLimitResetCredit(firstKey, expectedScope) + ).resolves.toMatchObject({ outcome: 'alreadyRedeemed', scope: expectedScope }) + expect(consume).toHaveBeenCalledTimes(2) + }) + + it('hydrates a pending attempt after restart and replays it without current-offer CAS', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const store = createStore(settings) + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const firstConsume = vi.fn().mockRejectedValue(new Error('provider response lost')) + const { CodexAccountService } = await import('./service') + const firstService = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: firstConsume + } as never, + createRuntimeHome() as never + ) + const key = '88888888-8888-4888-8888-888888888888' + + await expect(firstService.consumeRateLimitResetCredit(key, expectedScope)).rejects.toThrow( + 'provider response lost' + ) + state.codex = { + ...limits, + updatedAt: limits.updatedAt + 1, + rateLimitResetCredits: { ...limits.rateLimitResetCredits!, availableCount: 0 } + } + const replayConsume = vi.fn().mockResolvedValue({ outcome: 'alreadyRedeemed', state }) + const restarted = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: replayConsume + } as never, + createRuntimeHome() as never + ) + + await expect( + restarted.consumeRateLimitResetCredit('99999999-9999-4999-8999-999999999999', expectedScope) + ).rejects.toThrow('unknown outcome') + await expect( + restarted.consumeRateLimitResetCredit(key, { + ...expectedScope, + offerRevision: 'v1:different' + }) + ).rejects.toThrow('different reset scope') + await expect(restarted.consumeRateLimitResetCredit(key, expectedScope)).resolves.toMatchObject({ + outcome: 'alreadyRedeemed', + scope: expectedScope + }) + expect(replayConsume).toHaveBeenCalledOnce() + }) + + it('replays a settled outcome after restart without calling the provider', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const store = createStore(settings) + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const firstConsume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const { CodexAccountService } = await import('./service') + const firstService = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: firstConsume + } as never, + createRuntimeHome() as never + ) + const key = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + await firstService.consumeRateLimitResetCredit(key, expectedScope) + + const replayConsume = vi.fn() + const restarted = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: replayConsume + } as never, + createRuntimeHome() as never + ) + + await expect(restarted.consumeRateLimitResetCredit(key, expectedScope)).resolves.toMatchObject({ + outcome: 'reset', + scope: expectedScope + }) + await expect( + restarted.consumeRateLimitResetCredit('abababab-abab-4bab-8bab-abababababab', expectedScope) + ).rejects.toThrow('already attempted') + expect(replayConsume).not.toHaveBeenCalled() + }) + + it('never calls the provider when the pending durability barrier fails', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const store = createStore(settings) + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementationOnce(() => { + throw new Error('disk full') + }) + const consume = vi.fn() + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } as never, + createRuntimeHome() as never + ) + + await expect( + service.consumeRateLimitResetCredit('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', expectedScope) + ).rejects.toThrow('disk full') + expect(consume).not.toHaveBeenCalled() + expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) + }) + + it('keeps disk pending when settle persistence fails and recovers with the same key', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const store = createStore(settings) + const persist = store.replaceCodexResetCreditAttemptLedgerAndFlush.getMockImplementation()! + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation((ledger) => { + if (ledger.attempts[0]?.state === 'settled') { + throw new Error('settle disk full') + } + persist(ledger) + }) + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const firstConsume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const { CodexAccountService } = await import('./service') + const firstService = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: firstConsume + } as never, + createRuntimeHome() as never + ) + const key = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + + await expect(firstService.consumeRateLimitResetCredit(key, expectedScope)).rejects.toThrow( + 'settle disk full' + ) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { idempotencyKey: key, state: 'providerPending' } + ]) + + store.replaceCodexResetCreditAttemptLedgerAndFlush.mockImplementation(persist) + const replayConsume = vi.fn().mockResolvedValue({ outcome: 'alreadyRedeemed', state }) + const restarted = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: replayConsume + } as never, + createRuntimeHome() as never + ) + await expect(restarted.consumeRateLimitResetCredit(key, expectedScope)).resolves.toMatchObject({ + outcome: 'alreadyRedeemed' + }) + expect(replayConsume).toHaveBeenCalledOnce() + }) + + it('fails only reset operations closed when the durable ledger is corrupt', async () => { + const settings = createSettings() + const store = createStore(settings) + store.getCodexResetCreditAttemptLedger.mockImplementation(() => { + throw new Error('Codex reset-credit attempt ledger is corrupt') + }) + const consume = vi.fn() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + { + ...createRateLimits(), + consumeCodexRateLimitResetCredit: consume + } as never, + createRuntimeHome() as never + ) + + expect(service.listAccounts()).toMatchObject({ accounts: [] }) + await expect( + service.consumeRateLimitResetCredit('dddddddd-dddd-4ddd-8ddd-dddddddddddd', { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account-host', + accountRevision: 1, + offerRevision: 'v1:offer' + }) + ).rejects.toThrow('Codex reset-credit attempt ledger is corrupt') + await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow( + 'Codex reset-credit attempt ledger is corrupt' + ) + expect(consume).not.toHaveBeenCalled() + }) + + it('rejects a stale offer scope before calling the provider and permits a corrected retry key', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(settings) as never, + rateLimits as never, + createRuntimeHome() as never + ) + const idempotencyKey = '55555555-5555-4555-8555-555555555555' + + await expect( + service.consumeRateLimitResetCredit(idempotencyKey, { + ...expectedScope, + offerRevision: 'v1:stale' + }) + ).resolves.toMatchObject({ + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope: { ...expectedScope, offerRevision: 'v1:stale' }, + codex: { activeAccountId: account.id }, + rateLimits: state + }) + expect(consume).not.toHaveBeenCalled() + + await expect( + service.consumeRateLimitResetCredit(idempotencyKey, expectedScope) + ).resolves.toMatchObject({ outcome: 'reset' }) + expect(consume).toHaveBeenCalledOnce() + }) + + it('isolates a WSL reset to the selected distro account and immutable managed home', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-wsl') + const account = { + id: 'account-wsl', + email: 'wsl@example.com', + managedHomePath, + managedHomeRuntime: 'wsl' as const, + wslDistro: 'Ubuntu', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: account.id } + } + }) + const limits = createResetCreditLimits() + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const state = createResetRateLimitState(limits, target) + const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } + const expectedScope = buildCodexResetCreditExpectedScope({ target, account, limits })! + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(settings) as never, + rateLimits as never, + createRuntimeHome() as never + ) + + await expect( + service.consumeRateLimitResetCredit('66666666-6666-4666-8666-666666666666', expectedScope) + ).resolves.toMatchObject({ scope: expectedScope }) + expect(consume).toHaveBeenCalledWith({ + idempotencyKey: '66666666-6666-4666-8666-666666666666', + target, + codexHomePath: managedHomePath + }) + }) + + it('keeps a restarted pending WSL attempt isolated from another distro', async () => { + const ubuntuHome = createManagedHome(testState.userDataDir, 'account-ubuntu') + const debianHome = createManagedHome(testState.userDataDir, 'account-debian') + const ubuntu = { + id: 'account-ubuntu', + email: 'ubuntu@example.com', + managedHomePath: ubuntuHome, + managedHomeRuntime: 'wsl' as const, + wslDistro: 'Ubuntu', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const debian = { + ...ubuntu, + id: 'account-debian', + email: 'debian@example.com', + managedHomePath: debianHome, + wslDistro: 'Debian', + updatedAt: 2 + } + const settings = createSettings({ + codexManagedAccounts: [ubuntu, debian], + activeCodexManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: ubuntu.id, Debian: debian.id } + } + }) + const limits = createResetCreditLimits() + const ubuntuTarget = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const debianTarget = { runtime: 'wsl' as const, wslDistro: 'Debian' } + const state = createResetRateLimitState(limits, ubuntuTarget) + const ubuntuScope = buildCodexResetCreditExpectedScope({ + target: ubuntuTarget, + account: ubuntu, + limits + })! + const debianScope = buildCodexResetCreditExpectedScope({ + target: debianTarget, + account: debian, + limits + })! + const store = createStore(settings) + const { CodexAccountService } = await import('./service') + const firstService = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: vi + .fn() + .mockRejectedValue(new Error('Ubuntu response lost')) + } as never, + createRuntimeHome() as never + ) + await expect( + firstService.consumeRateLimitResetCredit('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', ubuntuScope) + ).rejects.toThrow('Ubuntu response lost') + + state.codexTarget = debianTarget + const debianConsume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const restarted = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: debianConsume + } as never, + createRuntimeHome() as never + ) + + await expect( + restarted.consumeRateLimitResetCredit('ffffffff-ffff-4fff-8fff-ffffffffffff', debianScope) + ).resolves.toMatchObject({ outcome: 'reset', scope: debianScope }) + expect(debianConsume).toHaveBeenCalledWith({ + idempotencyKey: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + target: debianTarget, + codexHomePath: debianHome + }) + }) + + it('preserves desktop reset support for the system-default Codex account', async () => { + const settings = createSettings() + const state = createResetRateLimitState(createResetCreditLimits()) + const consume = vi.fn().mockResolvedValue({ outcome: 'noCredit', state }) + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } + const runtimeHome = createRuntimeHome() + runtimeHome.prepareForRateLimitFetch.mockReturnValue(null) + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(settings) as never, + rateLimits as never, + runtimeHome as never + ) + + await expect(service.consumeCurrentRateLimitResetCredit()).resolves.toMatchObject({ + outcome: 'noCredit' + }) + expect(runtimeHome.prepareForRateLimitFetch).toHaveBeenCalledWith({ + runtime: 'host', + wslDistro: null + }) + expect(consume).toHaveBeenCalledWith({ + idempotencyKey: expect.any(String), + target: { runtime: 'host', wslDistro: null }, + codexHomePath: null + }) + }) + + it('routes a managed desktop reset through the durable coordinator', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const store = createStore(settings) + const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } as never, + createRuntimeHome() as never + ) + + await expect(service.consumeCurrentRateLimitResetCredit()).resolves.toEqual({ + outcome: 'reset', + state + }) + expect(consume).toHaveBeenCalledWith({ + idempotencyKey: expect.any(String), + target: { runtime: 'host', wslDistro: null }, + codexHomePath: managedHomePath + }) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { state: 'settled', outcome: 'reset', expectedScope: { accountId: account.id } } + ]) + }) + + it('reuses the durable pending key when desktop retries a managed reset after restart', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: account.id + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const store = createStore(settings) + const firstConsume = vi.fn().mockRejectedValue(new Error('provider response lost')) + const { CodexAccountService } = await import('./service') + const firstService = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: firstConsume + } as never, + createRuntimeHome() as never + ) + + await expect(firstService.consumeCurrentRateLimitResetCredit()).rejects.toThrow( + 'provider response lost' + ) + const pending = store.getCodexResetCreditAttemptLedger().attempts[0] + expect(pending).toMatchObject({ + state: 'providerPending', + expectedScope: { accountId: account.id } + }) + + state.codex = { + ...limits, + updatedAt: limits.updatedAt + 1, + rateLimitResetCredits: { ...limits.rateLimitResetCredits!, availableCount: 0 } + } + const replayConsume = vi.fn().mockResolvedValue({ outcome: 'alreadyRedeemed', state }) + const restarted = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: replayConsume + } as never, + createRuntimeHome() as never + ) + + await expect(restarted.consumeCurrentRateLimitResetCredit()).resolves.toEqual({ + outcome: 'alreadyRedeemed', + state + }) + expect(replayConsume).toHaveBeenCalledWith({ + idempotencyKey: pending?.idempotencyKey, + target: { runtime: 'host', wslDistro: null }, + codexHomePath: managedHomePath + }) + expect(store.getCodexResetCreditAttemptLedger().attempts).toMatchObject([ + { state: 'settled', outcome: 'alreadyRedeemed' } + ]) + }) + + it('blocks the system-default fallback while the exact target has a pending attempt', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: null + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const store = createStore(settings) + store.replaceCodexResetCreditAttemptLedgerAndFlush({ + version: 1, + attempts: [ + { + idempotencyKey: '12121212-1212-4212-8212-121212121212', + expectedScope, + state: 'providerPending' + } + ] + }) + const consume = vi.fn() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } as never, + createRuntimeHome() as never + ) + + await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') + expect(consume).not.toHaveBeenCalled() + }) + + it('unwedges the system-default reset after removing the account owning a pending attempt', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: null + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const store = createStore(settings) + store.replaceCodexResetCreditAttemptLedgerAndFlush({ + version: 1, + attempts: [ + { + idempotencyKey: '12121212-1212-4212-8212-121212121212', + expectedScope, + state: 'providerPending' + } + ] + }) + const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } as never, + createRuntimeHome() as never + ) + + // The orphan pending attempt wedges the target-scoped default reset until removal. + await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') + + await service.removeAccount('account-1') + + await expect(service.consumeCurrentRateLimitResetCredit()).resolves.toEqual({ + outcome: 'reset', + state + }) + expect(consume).toHaveBeenCalledTimes(1) + expect(store.getCodexResetCreditAttemptLedger().attempts).toEqual([]) + }) + + it('keeps reset attempts fail-closed when removal cannot persist their purge', async () => { + const managedHomePath = createManagedHome(testState.userDataDir, 'account-1') + const account = { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + managedHomeRuntime: 'host' as const, + wslDistro: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + const settings = createSettings({ + codexManagedAccounts: [account], + activeCodexManagedAccountId: null + }) + const limits = createResetCreditLimits() + const state = createResetRateLimitState(limits) + const expectedScope = buildCodexResetCreditExpectedScope({ + target: state.codexTarget, + account, + limits + })! + const store = createStore(settings) + store.replaceCodexResetCreditAttemptLedgerAndFlush({ + version: 1, + attempts: [ + { + idempotencyKey: '13131313-1313-4313-8313-131313131313', + expectedScope, + state: 'providerPending' + } + ] + }) + const consume = vi.fn().mockResolvedValue({ outcome: 'reset', state }) + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume + } as never, + createRuntimeHome() as never + ) + vi.spyOn(store, 'replaceCodexResetCreditAttemptLedgerAndFlush').mockImplementationOnce(() => { + throw new Error('disk full') + }) + + await expect(service.removeAccount('account-1')).rejects.toThrow('disk full') + await expect(service.consumeCurrentRateLimitResetCredit()).rejects.toThrow('unknown outcome') + expect(consume).not.toHaveBeenCalled() + }) + + it('does not reset a different system-default target after waiting in the mutation queue', async () => { + const settings = createSettings() + const state = createResetRateLimitState(createResetCreditLimits()) + let finishRefresh: (() => void) | undefined + const consume = vi.fn() + const rateLimits = { + ...createRateLimits(), + getState: vi.fn(() => state), + consumeCodexRateLimitResetCredit: consume, + refreshForCodexAccountChange: vi.fn( + () => + new Promise((resolve) => { + finishRefresh = resolve + }) + ) + } + const runtimeHome = createRuntimeHome() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(settings) as never, + rateLimits as never, + runtimeHome as never + ) + + const queueBlocker = service.selectAccount(null) + await vi.waitFor(() => expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledOnce()) + const resetting = service.consumeCurrentRateLimitResetCredit() + state.codexTarget = { runtime: 'wsl', wslDistro: 'Ubuntu' } + finishRefresh?.() + + await queueBlocker + await expect(resetting).rejects.toThrow('target changed') + expect(consume).not.toHaveBeenCalled() + expect(runtimeHome.prepareForRateLimitFetch).not.toHaveBeenCalled() + }) + it('removes command listeners when Codex login times out', async () => { vi.resetModules() vi.useFakeTimers() diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 5c2f419ca4c..b57c277e526 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -12,6 +12,20 @@ import type { CodexRateLimitAccountsState, CodexSystemDefaultIdentity } from '../../shared/types' +import type { + CodexRateLimitResetOutcome, + CodexRateLimitResetResult, + RateLimitState, + RateLimitRuntimeTarget +} from '../../shared/rate-limit-types' +import { + buildCodexResetCreditExpectedScope, + type CodexResetCreditExpectedScope +} from '../../shared/codex-reset-credit-scope' +import type { + CodexResetCreditAttemptLedger, + DurableCodexResetCreditAttempt +} from '../../shared/codex-reset-credit-attempt-ledger' import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' @@ -90,6 +104,79 @@ type ManagedHomeLocation = { wslLinuxHomePath: string | null } +export type CodexResetCreditRejectedBeforeProviderReason = + | 'targetChanged' + | 'accountChanged' + | 'accountRevisionChanged' + | 'accountRuntimeChanged' + | 'offerUnavailable' + | 'offerChanged' + +export type CodexResetCreditConsumedResult = { + outcome: CodexRateLimitResetOutcome + scope: CodexResetCreditExpectedScope + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState +} + +export type CodexResetCreditRejectedBeforeProviderResult = { + status: 'rejectedBeforeProvider' + retryDisposition: 'discardAttempt' + reason: CodexResetCreditRejectedBeforeProviderReason + scope: CodexResetCreditExpectedScope + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState +} + +export type CodexResetCreditConsumeResult = + | CodexResetCreditConsumedResult + | CodexResetCreditRejectedBeforeProviderResult + +type CodexResetCreditAttempt = { + expectedScope: CodexResetCreditExpectedScope + scopeKey: string + accountScopeKey: string + state: 'fresh' | 'providerPending' | 'settled' + promise: Promise | null + settledOutcome: CodexRateLimitResetOutcome | null +} + +class CodexResetCreditScopeRejection extends Error { + constructor( + readonly reason: CodexResetCreditRejectedBeforeProviderReason, + readonly rateLimits: RateLimitState, + message: string + ) { + super(message) + this.name = 'CodexResetCreditScopeRejection' + } +} + +function resetScopeKey(scope: CodexResetCreditExpectedScope): string { + return JSON.stringify([ + scope.target.runtime, + scope.target.wslDistro, + scope.accountId, + scope.accountRevision, + scope.offerRevision + ]) +} + +function resetAccountScopeKey( + scope: Pick +): string { + return JSON.stringify([ + scope.target.runtime, + scope.target.wslDistro, + scope.accountId, + scope.accountRevision + ]) +} + +function sameRateLimitTarget(left: RateLimitRuntimeTarget, right: RateLimitRuntimeTarget): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -158,6 +245,11 @@ function loginAuthChanged( export class CodexAccountService { // Why: serialize the read-modify-write of settings; overlapping calls (e.g. double-click Add) would lose updates. private mutationQueue: Promise = Promise.resolve() + private readonly resetAttemptsByKey = new Map() + private readonly resetAttemptKeyByOffer = new Map() + private readonly unresolvedResetKeyByAccountScope = new Map() + private durableResetLedger: CodexResetCreditAttemptLedger | null = null + private resetLedgerLoadError: Error | null = null constructor( private readonly store: Store, @@ -165,6 +257,7 @@ export class CodexAccountService { private readonly runtimeHome: CodexRuntimeHomeService, private readonly lifecycle: CodexAccountServiceLifecycle = {} ) { + this.hydrateResetCreditAttempts() this.safeSyncCanonicalConfigToManagedHomes() } @@ -202,6 +295,392 @@ export class CodexAccountService { return this.serializeMutation(() => this.doSelectAccount(accountId, target)) } + consumeRateLimitResetCredit( + idempotencyKey: string, + expectedScope: CodexResetCreditExpectedScope + ): Promise { + if (this.resetLedgerLoadError) { + return Promise.reject(this.resetLedgerLoadError) + } + const scopeKey = resetScopeKey(expectedScope) + const accountScopeKey = resetAccountScopeKey(expectedScope) + const existing = this.resetAttemptsByKey.get(idempotencyKey) + if (existing) { + if (existing.scopeKey !== scopeKey) { + return Promise.reject(new Error('That idempotency key belongs to a different reset scope.')) + } + if (existing.state === 'settled' && existing.settledOutcome) { + return this.serializeMutation(async () => { + const { rateLimits } = this.validateResetCreditScope(expectedScope, false) + return { + outcome: existing.settledOutcome!, + scope: existing.expectedScope, + codex: this.getSnapshot(), + rateLimits + } + }) + } + if (existing.promise) { + return existing.promise + } + return this.startResetCreditAttempt(idempotencyKey, expectedScope, existing) + } + + const unresolvedKey = this.unresolvedResetKeyByAccountScope.get(accountScopeKey) + if (unresolvedKey && unresolvedKey !== idempotencyKey) { + return Promise.reject( + new Error('A previous reset attempt for this account still has an unknown outcome.') + ) + } + const claimedKey = this.resetAttemptKeyByOffer.get(scopeKey) + if (claimedKey && claimedKey !== idempotencyKey) { + return Promise.reject(new Error('That reset-credit offer was already attempted.')) + } + + const attempt: CodexResetCreditAttempt = { + expectedScope, + scopeKey, + accountScopeKey, + state: 'fresh', + promise: null, + settledOutcome: null + } + this.resetAttemptsByKey.set(idempotencyKey, attempt) + this.resetAttemptKeyByOffer.set(scopeKey, idempotencyKey) + return this.startResetCreditAttempt(idempotencyKey, expectedScope, attempt) + } + + async consumeCurrentRateLimitResetCredit(): Promise { + if (this.resetLedgerLoadError) { + throw this.resetLedgerLoadError + } + const initialRateLimits = this.rateLimits.getState() + const initialTarget = { ...initialRateLimits.codexTarget } + const initialSettings = this.store.getSettings() + const selectedAccountId = getSelectedCodexAccountIdForTarget(initialSettings, initialTarget) + if (selectedAccountId) { + const account = initialSettings.codexManagedAccounts.find( + (candidate) => candidate.id === selectedAccountId + ) + const pendingAttempt = account + ? this.getPendingResetAttemptForAccount(initialTarget, account) + : null + const expectedScope = + pendingAttempt?.expectedScope ?? + (account + ? buildCodexResetCreditExpectedScope({ + target: initialTarget, + account: this.toSummary(account), + limits: initialRateLimits.codex + }) + : null) + if (!expectedScope) { + throw new Error('The managed Codex reset-credit offer is no longer available.') + } + // Why: do not enter the mutation queue first; the coordinator owns that + // queue and nested serialization would deadlock behind this operation. + const result = await this.consumeRateLimitResetCredit( + pendingAttempt?.idempotencyKey ?? randomUUID(), + expectedScope + ) + if ('status' in result) { + throw new Error('The Codex account or reset offer changed before reset.') + } + return { outcome: result.outcome, state: result.rateLimits } + } + + return this.serializeMutation(async () => { + if (this.resetLedgerLoadError) { + throw this.resetLedgerLoadError + } + const target = this.rateLimits.getState().codexTarget + if (!sameRateLimitTarget(target, initialTarget)) { + throw new Error('The active Codex rate-limit target changed before reset.') + } + if (getSelectedCodexAccountIdForTarget(this.store.getSettings(), target)) { + throw new Error('The selected Codex account changed before reset.') + } + if (this.hasPendingResetForTarget(target)) { + throw new Error('A previous reset attempt for this target still has an unknown outcome.') + } + const codexHomePath = this.runtimeHome.prepareForRateLimitFetch(target) + return this.rateLimits.consumeCodexRateLimitResetCredit({ + idempotencyKey: randomUUID(), + target, + codexHomePath + }) + }) + } + + private getPendingResetAttemptForAccount( + target: RateLimitRuntimeTarget, + account: CodexManagedAccount + ): { idempotencyKey: string; expectedScope: CodexResetCreditExpectedScope } | null { + const accountScopeKey = resetAccountScopeKey({ + target, + accountId: account.id, + accountRevision: account.updatedAt + }) + const idempotencyKey = this.unresolvedResetKeyByAccountScope.get(accountScopeKey) + if (!idempotencyKey) { + return null + } + const attempt = this.resetAttemptsByKey.get(idempotencyKey) + if (attempt?.state !== 'providerPending') { + throw new Error('Codex reset-credit attempt state is inconsistent.') + } + // Why: a durable providerPending attempt can only be resolved with its original key. + return { idempotencyKey, expectedScope: attempt.expectedScope } + } + + private hasPendingResetForTarget(target: RateLimitRuntimeTarget): boolean { + return [...this.resetAttemptsByKey.values()].some( + (attempt) => + attempt.state === 'providerPending' && + sameRateLimitTarget(attempt.expectedScope.target, target) + ) + } + + private startResetCreditAttempt( + idempotencyKey: string, + expectedScope: CodexResetCreditExpectedScope, + attempt: CodexResetCreditAttempt + ): Promise { + const promise = this.serializeMutation(async (): Promise => { + const isFresh = attempt.state === 'fresh' + let validation: { managedHomePath: string; rateLimits: RateLimitState } + try { + validation = this.validateResetCreditScope(expectedScope, isFresh) + } catch (error) { + if (isFresh && error instanceof CodexResetCreditScopeRejection) { + this.releaseFreshResetAttempt(idempotencyKey, attempt) + return { + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: error.reason, + scope: expectedScope, + codex: this.getSnapshot(), + rateLimits: error.rateLimits + } + } + throw error + } + if (isFresh) { + this.persistResetAttempt({ + idempotencyKey, + expectedScope, + state: 'providerPending' + }) + attempt.state = 'providerPending' + this.unresolvedResetKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey) + } + const { outcome, state } = await this.rateLimits.consumeCodexRateLimitResetCredit({ + idempotencyKey, + target: expectedScope.target, + codexHomePath: validation.managedHomePath + }) + // Why: queued account selection may start as soon as this mutation resolves; + // capture both account selection and usage before releasing the queue. + const result: CodexResetCreditConsumedResult = { + outcome, + scope: expectedScope, + codex: this.getSnapshot(), + rateLimits: state + } + this.persistResetAttempt({ + idempotencyKey, + expectedScope, + state: 'settled', + outcome + }) + attempt.state = 'settled' + attempt.settledOutcome = outcome + if (this.unresolvedResetKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { + this.unresolvedResetKeyByAccountScope.delete(attempt.accountScopeKey) + } + return result + }) + attempt.promise = promise + void promise.then( + () => { + attempt.promise = null + }, + () => { + attempt.promise = null + if (attempt.state === 'fresh') { + this.releaseFreshResetAttempt(idempotencyKey, attempt) + } + } + ) + return promise + } + + private validateResetCreditScope( + expectedScope: CodexResetCreditExpectedScope, + requireCurrentOffer: boolean + ): { managedHomePath: string; rateLimits: RateLimitState } { + const rateLimitState = this.rateLimits.getState() + if (!sameRateLimitTarget(rateLimitState.codexTarget, expectedScope.target)) { + throw new CodexResetCreditScopeRejection( + 'targetChanged', + rateLimitState, + 'The active Codex rate-limit target changed before reset.' + ) + } + + const settings = this.store.getSettings() + if ( + getSelectedCodexAccountIdForTarget(settings, expectedScope.target) !== expectedScope.accountId + ) { + throw new CodexResetCreditScopeRejection( + 'accountChanged', + rateLimitState, + 'The selected Codex account changed before reset.' + ) + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === expectedScope.accountId + ) + if (!account || account.updatedAt !== expectedScope.accountRevision) { + throw new CodexResetCreditScopeRejection( + 'accountRevisionChanged', + rateLimitState, + 'The selected Codex account was updated before reset.' + ) + } + const normalizedAccountTarget = normalizeCodexAccountSelectionTarget( + getCodexSelectionTargetForAccount(account) + ) + if (!sameRateLimitTarget(normalizedAccountTarget, expectedScope.target)) { + throw new CodexResetCreditScopeRejection( + 'accountRuntimeChanged', + rateLimitState, + 'The selected Codex account belongs to a different runtime.' + ) + } + + const currentScope = buildCodexResetCreditExpectedScope({ + target: rateLimitState.codexTarget, + account: this.toSummary(account), + limits: rateLimitState.codex + }) + // Why: a same-key replay resolves an already-started provider mutation; + // its credit snapshot may have refreshed, but its account/runtime identity may not change. + if (requireCurrentOffer && !currentScope) { + throw new CodexResetCreditScopeRejection( + 'offerUnavailable', + rateLimitState, + 'The Codex reset-credit offer is no longer available.' + ) + } + if ( + requireCurrentOffer && + currentScope && + resetScopeKey(expectedScope) !== resetScopeKey(currentScope) + ) { + throw new CodexResetCreditScopeRejection( + 'offerChanged', + rateLimitState, + 'The Codex reset-credit offer changed before reset.' + ) + } + + return { managedHomePath: account.managedHomePath, rateLimits: rateLimitState } + } + + private hydrateResetCreditAttempts(): void { + try { + const ledger = this.store.getCodexResetCreditAttemptLedger() + this.durableResetLedger = ledger + for (const durable of ledger.attempts) { + const scopeKey = resetScopeKey(durable.expectedScope) + const accountScopeKey = resetAccountScopeKey(durable.expectedScope) + this.resetAttemptsByKey.set(durable.idempotencyKey, { + expectedScope: durable.expectedScope, + scopeKey, + accountScopeKey, + state: durable.state, + promise: null, + settledOutcome: durable.state === 'settled' ? durable.outcome : null + }) + this.resetAttemptKeyByOffer.set(scopeKey, durable.idempotencyKey) + if (durable.state === 'providerPending') { + this.unresolvedResetKeyByAccountScope.set(accountScopeKey, durable.idempotencyKey) + } + } + } catch (error) { + this.resetLedgerLoadError = + error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt') + } + } + + private persistResetAttempt(nextAttempt: DurableCodexResetCreditAttempt): void { + if (!this.durableResetLedger) { + throw ( + this.resetLedgerLoadError ?? new Error('Codex reset-credit attempt ledger is unavailable') + ) + } + const index = this.durableResetLedger.attempts.findIndex( + (attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey + ) + const attempts = [...this.durableResetLedger.attempts] + if (index === -1) { + attempts.push(nextAttempt) + } else { + attempts[index] = nextAttempt + } + const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } + this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + this.durableResetLedger = structuredClone(nextLedger) + } + + private releaseFreshResetAttempt(idempotencyKey: string, attempt: CodexResetCreditAttempt): void { + if (attempt.state !== 'fresh') { + return + } + this.resetAttemptsByKey.delete(idempotencyKey) + if (this.resetAttemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) { + this.resetAttemptKeyByOffer.delete(attempt.scopeKey) + } + } + + // Why: a removed account's managed home is gone, so its unresolved providerPending + // attempt can never validate or be replayed; drop it so a target-scoped default reset + // is not wedged forever by hasPendingResetForTarget matching the orphan. + private discardResetAttemptsForRemovedAccount(accountId: string): void { + const staleAttempts: [string, CodexResetCreditAttempt][] = [] + for (const [idempotencyKey, attempt] of this.resetAttemptsByKey) { + if (attempt.expectedScope.accountId === accountId) { + staleAttempts.push([idempotencyKey, attempt]) + } + } + if (staleAttempts.length === 0) { + return + } + const staleKeySet = new Set(staleAttempts.map(([idempotencyKey]) => idempotencyKey)) + if (this.durableResetLedger) { + const attempts = this.durableResetLedger.attempts.filter( + (attempt) => !staleKeySet.has(attempt.idempotencyKey) + ) + if (attempts.length !== this.durableResetLedger.attempts.length) { + const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts } + // Persist first so a failed durability barrier leaves the in-memory + // fail-closed guards aligned with the ledger that will reload. + this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger) + this.durableResetLedger = structuredClone(nextLedger) + } + } + for (const [idempotencyKey, attempt] of staleAttempts) { + this.resetAttemptsByKey.delete(idempotencyKey) + if (this.resetAttemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) { + this.resetAttemptKeyByOffer.delete(attempt.scopeKey) + } + if (this.unresolvedResetKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) { + this.unresolvedResetKeyByAccountScope.delete(attempt.accountScopeKey) + } + } + } + // Why: quota probes against a cold per-account CODEX_HOME can take 10–25s // (RPC + PTY fallback) and queue behind an in-flight global usage refresh. // The refresh synchronously flips usage to "fetching" before its first await, @@ -351,6 +830,7 @@ export class CodexAccountService { // Why: a removed account can no longer appear in the switcher dropdown, // so purge its cached usage to avoid stale entries. this.rateLimits.evictInactiveCodexCache(accountId) + this.discardResetAttemptsForRemovedAccount(accountId) this.startQuotaRefreshInBackground( getSelectedCodexAccountIdForTarget(settings, getCodexSelectionTargetForAccount(account)) === accountId diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts index b53d87d5d83..1224d4e8275 100644 --- a/src/main/ipc/rate-limits.test.ts +++ b/src/main/ipc/rate-limits.test.ts @@ -15,28 +15,46 @@ vi.mock('electron', () => ({ import { registerRateLimitHandlers } from './rate-limits' import type { RateLimitService } from '../rate-limits/service' import type { RateLimitState } from '../../shared/rate-limit-types' +import type { CodexAccountService } from '../codex-accounts/service' + +function makeCodexAccounts() { + const consumeCurrentRateLimitResetCredit = vi.fn(() => + Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState }) + ) + return { + service: { consumeCurrentRateLimitResetCredit } as unknown as CodexAccountService, + consumeCurrentRateLimitResetCredit + } +} function makeService(): { service: RateLimitService refresh: ReturnType refreshGrok: ReturnType + consumeCodexRateLimitResetCredit: ReturnType } { const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) const refreshGrok = vi.fn(() => Promise.resolve({} as RateLimitState)) + const consumeCodexRateLimitResetCredit = vi.fn(() => + Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState }) + ) const service = { getState: vi.fn(() => ({}) as RateLimitState), refresh, refreshGrok, refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), - consumeCodexRateLimitResetCredit: vi.fn(() => - Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState }) - ), + consumeCodexRateLimitResetCredit, setPollingInterval: vi.fn(() => Promise.resolve()), fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()), fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve()) } - return { service: service as unknown as RateLimitService, refresh, refreshGrok } + return { + service: service as unknown as RateLimitService, + refresh, + refreshGrok, + consumeCodexRateLimitResetCredit + } } describe('registerRateLimitHandlers', () => { @@ -46,7 +64,7 @@ describe('registerRateLimitHandlers', () => { it('registers a refreshMiniMax channel that delegates to refresh()', async () => { const { service, refresh } = makeService() - registerRateLimitHandlers(service) + registerRateLimitHandlers(service, makeCodexAccounts().service) const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax') expect(handler).toBeDefined() await handler!({}) @@ -55,7 +73,7 @@ describe('registerRateLimitHandlers', () => { it('keeps the existing rate-limit channels registered', () => { const { service } = makeService() - registerRateLimitHandlers(service) + registerRateLimitHandlers(service, makeCodexAccounts().service) expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true) expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true) expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true) @@ -64,10 +82,22 @@ describe('registerRateLimitHandlers', () => { it('registers a refreshGrok channel that delegates to refreshGrok()', async () => { const { service, refreshGrok } = makeService() - registerRateLimitHandlers(service) + registerRateLimitHandlers(service, makeCodexAccounts().service) const handler = ipcState.handleHandlers.get('rateLimits:refreshGrok') expect(handler).toBeDefined() await handler!({}) expect(refreshGrok).toHaveBeenCalledTimes(1) }) + + it('serializes desktop reset consumption through CodexAccountService', async () => { + const { service, consumeCodexRateLimitResetCredit } = makeService() + const codexAccounts = makeCodexAccounts() + registerRateLimitHandlers(service, codexAccounts.service) + const handler = ipcState.handleHandlers.get('rateLimits:consumeCodexResetCredit') + + await handler!({}) + + expect(codexAccounts.consumeCurrentRateLimitResetCredit).toHaveBeenCalledOnce() + expect(consumeCodexRateLimitResetCredit).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index b906ddce51f..7882c398a0e 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -1,15 +1,20 @@ import { ipcMain } from 'electron' import type { RateLimitService } from '../rate-limits/service' import type { RateLimitRuntimeTarget } from '../../shared/rate-limit-types' +import type { CodexAccountService } from '../codex-accounts/service' -export function registerRateLimitHandlers(rateLimits: RateLimitService): void { +export function registerRateLimitHandlers( + rateLimits: RateLimitService, + codexAccounts: CodexAccountService +): void { ipcMain.handle('rateLimits:get', () => rateLimits.getState()) ipcMain.handle('rateLimits:refresh', () => rateLimits.refresh()) ipcMain.handle('rateLimits:refreshCodexForTarget', (_event, target: RateLimitRuntimeTarget) => rateLimits.refreshCodexForTarget(target) ) + // Why: managed desktop resets must share the mobile mutation queue and durable ledger. ipcMain.handle('rateLimits:consumeCodexResetCredit', () => - rateLimits.consumeCodexRateLimitResetCredit() + codexAccounts.consumeCurrentRateLimitResetCredit() ) ipcMain.handle('rateLimits:refreshClaudeForTarget', (_event, target: RateLimitRuntimeTarget) => rateLimits.refreshClaudeForTarget(target) diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index bc3de000667..4bb5727354c 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -494,7 +494,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() - expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) + expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() expect(registerJiraHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index a11fd2c1367..7f2e82d1197 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -141,7 +141,7 @@ export function registerCoreHandlers( registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() - registerRateLimitHandlers(rateLimits) + registerRateLimitHandlers(rateLimits, codexAccounts) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) registerHostedReviewHandlers(store, stats) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 356c8d4b179..7cd465e6d54 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -335,6 +335,75 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) + it('clone-reads and synchronously persists the main-owned Codex reset ledger', async () => { + const store = await createStore() + const ledger = { + version: 1 as const, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: { + target: { runtime: 'host' as const, wslDistro: null }, + accountId: 'account-host', + accountRevision: 42, + offerRevision: 'v1:offer' + }, + state: 'providerPending' as const + } + ] + } + + store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger) + const firstRead = store.getCodexResetCreditAttemptLedger() + firstRead.attempts.splice(0, 1) + + expect(store.getCodexResetCreditAttemptLedger()).toEqual(ledger) + expect((readDataFile() as PersistedState).codexResetCreditAttemptLedger).toEqual(ledger) + }) + + it('rolls the in-memory Codex reset ledger back when its sync flush fails', async () => { + const store = await createStore() + const before = store.getCodexResetCreditAttemptLedger() + vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('disk full') + }) + + expect(() => + store.replaceCodexResetCreditAttemptLedgerAndFlush({ + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: { + target: { runtime: 'host', wslDistro: null }, + accountId: 'account-host', + accountRevision: 42, + offerRevision: 'v1:offer' + }, + state: 'providerPending' + } + ] + }) + ).toThrow('disk full') + + expect(store.getCodexResetCreditAttemptLedger()).toEqual(before) + }) + + it('preserves a corrupt Codex reset ledger as a fail-closed read error', async () => { + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + codexResetCreditAttemptLedger: { + version: 1, + attempts: [{ state: 'providerPending' }] + } + }) + + const store = await createStore() + expect(() => store.getCodexResetCreditAttemptLedger()).toThrow( + 'Codex reset-credit attempt ledger is corrupt' + ) + }) + it('does not restore a terminal tab after its durable close flush returns', async () => { const store = await createStore() const worktreeId = 'repo-1::/tmp/worktree-1' diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 460111d3710..ef22ddcc276 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -178,6 +178,10 @@ import { } from '../shared/feature-interactions' import { normalizeContextualTourIds } from '../shared/contextual-tours' import { normalizeFeatureTipIds } from '../shared/feature-tips' +import { + parseCodexResetCreditAttemptLedger, + type CodexResetCreditAttemptLedger +} from '../shared/codex-reset-credit-attempt-ledger' import { normalizeManualRepoOrder } from '../shared/manual-repo-order' import { DEFAULT_WORKSPACE_STATUS_ID, @@ -3741,6 +3745,29 @@ export class Store { this.activeViewPreference.flushOrThrow() } + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { + return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger) + } + + replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { + if (this.writesFrozen) { + throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') + } + const next = parseCodexResetCreditAttemptLedger(ledger) + const previous = this.state.codexResetCreditAttemptLedger + ? structuredClone(this.state.codexResetCreditAttemptLedger) + : undefined + this.state.codexResetCreditAttemptLedger = next + try { + this.flushOrThrow() + } catch (error) { + // Why: callers use a successful return as the durability barrier before + // handing a scarce-credit mutation to the provider. + this.state.codexResetCreditAttemptLedger = previous + throw error + } + } + // ── Repos ────────────────────────────────────────────────────────── getRepos(): Repo[] { diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 150cdf5e9d8..dfe2755bdc2 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -7,7 +7,7 @@ import { EventEmitter } from 'node:events' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { RateLimitService } from './service' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' -import { fetchCodexRateLimits } from './codex-fetcher' +import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax-fetcher' @@ -22,6 +22,7 @@ vi.mock('./claude-fetcher', () => ({ })) vi.mock('./codex-fetcher', () => ({ + consumeCodexRateLimitResetCredit: vi.fn(), fetchCodexRateLimits: vi.fn() })) @@ -1449,6 +1450,131 @@ describe('RateLimitService', () => { ) }) + it('reuses a caller-provided idempotency key when consuming a Codex reset credit', async () => { + const service = new RateLimitService() + const idempotencyKey = '11111111-1111-4111-8111-111111111111' + service.setCodexHomePathResolver(() => '/tmp/codex-home') + vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset') + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now())) + + await expect( + service.consumeCodexRateLimitResetCredit({ + idempotencyKey, + target: { runtime: 'host', wslDistro: null }, + codexHomePath: '/tmp/codex-home' + }) + ).resolves.toMatchObject({ outcome: 'reset' }) + expect(consumeCodexRateLimitResetCredit).toHaveBeenCalledWith({ + codexHomePath: '/tmp/codex-home', + idempotencyKey + }) + }) + + it('returns a refreshed scoped state without overwriting a target selected during reset', async () => { + const service = new RateLimitService() + const idempotencyKey = '22222222-2222-4222-8222-222222222222' + const consume = vi.mocked(consumeCodexRateLimitResetCredit) + let resolveConsume: ((outcome: 'reset') => void) | undefined + consume.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveConsume = resolve + }) + ) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now())) + + service.setCodexHomePathResolver(() => '/tmp/new-selection') + const pending = service.consumeCodexRateLimitResetCredit({ + idempotencyKey, + target: { runtime: 'host', wslDistro: null }, + codexHomePath: '/tmp/approved-selection' + }) + await vi.waitFor(() => expect(consume).toHaveBeenCalledOnce()) + service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + resolveConsume?.('reset') + + await expect(pending).resolves.toMatchObject({ + outcome: 'reset', + state: { + codexTarget: { runtime: 'host', wslDistro: null }, + codex: { session: { usedPercent: 0 } } + } + }) + expect(consume).toHaveBeenCalledWith({ + codexHomePath: '/tmp/approved-selection', + idempotencyKey + }) + expect(fetchCodexRateLimits).toHaveBeenCalledWith( + expect.objectContaining({ + codexHomePath: '/tmp/approved-selection', + signal: expect.any(AbortSignal) + }) + ) + expect(service.getState().codexTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(service.getState().codex).toBeNull() + }) + + it('keeps the reset result scoped when the active target changes during its refresh', async () => { + const service = new RateLimitService() + const idempotencyKey = '33333333-3333-4333-8333-333333333333' + const hostRefresh = deferred() + service.setCodexHomePathResolver((target) => + target?.runtime === 'wsl' ? '/tmp/wsl-selection' : '/tmp/approved-selection' + ) + vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset') + vi.mocked(fetchCodexRateLimits) + .mockReturnValueOnce(hostRefresh.promise) + .mockResolvedValueOnce(okProvider('codex', 73, Date.now())) + + const pendingReset = service.consumeCodexRateLimitResetCredit({ + idempotencyKey, + target: { runtime: 'host', wslDistro: null }, + codexHomePath: '/tmp/approved-selection' + }) + await vi.waitFor(() => expect(fetchCodexRateLimits).toHaveBeenCalledOnce()) + + await service.refreshCodexForTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + hostRefresh.resolve(okProvider('codex', 0, Date.now())) + + await expect(pendingReset).resolves.toMatchObject({ + outcome: 'reset', + state: { + codexTarget: { runtime: 'host', wslDistro: null }, + codex: { session: { usedPercent: 0 } } + } + }) + expect(service.getState()).toMatchObject({ + codexTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + codex: { session: { usedPercent: 73 } } + }) + }) + + it('does not let an older full refresh overwrite the post-reset Codex state', async () => { + const service = new RateLimitService() + const slowClaude = deferred() + service.setCodexHomePathResolver(() => '/tmp/approved-selection') + vi.mocked(fetchClaudeRateLimits).mockReturnValueOnce(slowClaude.promise) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(okProvider('codex', 100, Date.now())) + .mockResolvedValueOnce(okProvider('codex', 0, Date.now())) + vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset') + + const olderRefresh = service.refresh() + await vi.waitFor(() => expect(fetchCodexRateLimits).toHaveBeenCalledOnce()) + + await service.consumeCodexRateLimitResetCredit({ + idempotencyKey: '44444444-4444-4444-8444-444444444444', + target: { runtime: 'host', wslDistro: null }, + codexHomePath: '/tmp/approved-selection' + }) + expect(service.getState().codex?.session?.usedPercent).toBe(0) + + slowClaude.resolve(okProvider('claude', 20, Date.now())) + await olderRefresh + + expect(service.getState().codex?.session?.usedPercent).toBe(0) + }) + it('uses the initialized WSL target for active Codex rate-limit fetches', async () => { const service = new RateLimitService() const wslCodexHome = diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index c097d6dad42..a1700729ac8 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1,11 +1,11 @@ /* eslint-disable max-lines -- Why: centralizes polling, stale-data handling, account-switch fetch semantics, and renderer push coordination in one place */ import type { BrowserWindow } from 'electron' -import { randomUUID } from 'node:crypto' import type { CodexRateLimitResetResult, RateLimitState, ProviderRateLimits, - InactiveAccountUsage + InactiveAccountUsage, + RateLimitRuntimeTarget } from '../../shared/rate-limit-types' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import type { InactiveClaudeAccountInfo } from './claude-fetcher' @@ -415,25 +415,38 @@ export class RateLimitService { return this.getState() } - async consumeCodexRateLimitResetCredit(): Promise { - const codexTarget = this.codexFetchTarget - const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null + async consumeCodexRateLimitResetCredit(options: { + idempotencyKey: string + target: RateLimitRuntimeTarget + codexHomePath: string | null + }): Promise { + const codexTarget = normalizeCodexAccountSelectionTarget(options.target) + const codexHomePath = options.codexHomePath + const scopedStateBeforeReset = this.getState() const missingWslCodexHome = codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) if (missingWslCodexHome) { - await this.fetchCodexOnly({ force: true }) + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') } try { const outcome = await consumeCodexRateLimitResetCredit({ codexHomePath, - idempotencyKey: randomUUID() + idempotencyKey: options.idempotencyKey }) - await this.fetchCodexOnly({ force: true }) - return { outcome, state: this.getState() } + const state = await this.fetchCodexResetResultState( + codexTarget, + codexHomePath, + scopedStateBeforeReset + ) + return { outcome, state } } catch (error) { - await this.fetchCodexOnly({ force: true }) + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } throw error } } @@ -1237,6 +1250,54 @@ export class RateLimitService { } } + private async fetchCodexResetResultState( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null, + stateBeforeReset: RateLimitState + ): Promise { + const controller = this.beginFetchCycle() + let fresh: ProviderRateLimits + try { + fresh = await fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal: controller.signal + }) + } catch (error) { + fresh = { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: toErrorMessage(error), + status: 'error' + } + } finally { + this.finishFetchCycle(controller) + } + + const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) + const currentHomePath = this.codexHomePathResolver?.(target) ?? null + const stillActive = + this.isSameCodexTarget(this.codexFetchTarget, target) && + this.getCodexProvenance(target, currentHomePath) === + this.getCodexProvenance(target, codexHomePath) + if (stillActive) { + // Why: this post-redemption read is newer than every Codex fetch that + // started before it, so invalidate those results before publishing it. + this.codexFetchGeneration += 1 + this.trackActiveFailureStreak('codex', fresh) + this.updateState({ + ...this.state, + codex: this.applyStalePolicy(fresh, this.state.codex) + }) + } + + // Why: the caller must receive the redeemed target even if the global UI + // switched targets while the provider mutation was in flight. + return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } + } + private shouldAllowCodexPtyFallback(): boolean { // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. return process.platform !== 'win32' diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 5003a15df90..63957f874c0 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -8,6 +8,7 @@ const MOBILE_DYNAMIC_RPC_METHODS = [ // mobile source scan below, but still must stay mobile-authorized. 'accounts.selectClaude', 'accounts.selectCodex', + 'accounts.selectCodexForTarget', 'terminal.createAgentSession', 'terminal.ensureAgentSession', 'github.updateIssue', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6b54d59eb45..95578891133 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1881,6 +1881,124 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1') }) + it('advertises safe Codex reset-credit RPC support as a static capability', () => { + const runtime = createRuntime() + + expect(runtime.getStatus().capabilities).toContain('accounts.codex-reset-credit.v1') + }) + + it('routes mobile Codex reset consumption through the account mutation coordinator', async () => { + const runtime = createRuntime() + const expectedScope = { + target: { runtime: 'host' as const, wslDistro: null }, + accountId: 'codex-account', + accountRevision: 42, + offerRevision: 'v1:offer' + } + const capturedCodex = { + accounts: [], + activeAccountId: expectedScope.accountId, + activeAccountIdsByRuntime: { host: expectedScope.accountId, wsl: {} } + } + const capturedRateLimits = { + codexTarget: expectedScope.target, + marker: 'captured-before-queue-advanced' + } + const codexAccounts = { + consumeRateLimitResetCredit: vi.fn().mockResolvedValue({ + outcome: 'reset', + scope: expectedScope, + codex: capturedCodex, + rateLimits: capturedRateLimits + }), + listAccounts: vi.fn(() => ({ + accounts: [], + activeAccountId: 'queued-next-account', + activeAccountIdsByRuntime: { host: 'queued-next-account', wsl: {} } + })) + } + const rateLimits = { + consumeCodexRateLimitResetCredit: vi.fn(), + getState: vi.fn(() => ({ + codexTarget: expectedScope.target, + marker: 'after-queue-advanced' + })) + } + runtime.setAccountServices({ + claudeAccounts: { + listAccounts: vi.fn(() => ({ accounts: [], activeAccountId: null })) + }, + codexAccounts, + rateLimits + } as never) + + const result = await runtime.consumeCodexRateLimitResetCredit( + '11111111-1111-4111-8111-111111111111', + expectedScope + ) + + expect(result).toMatchObject({ + outcome: 'reset', + scope: expectedScope, + snapshot: { codex: capturedCodex, rateLimits: capturedRateLimits } + }) + expect(codexAccounts.listAccounts).not.toHaveBeenCalled() + expect(rateLimits.getState).not.toHaveBeenCalled() + expect(codexAccounts.consumeRateLimitResetCredit).toHaveBeenCalledWith( + '11111111-1111-4111-8111-111111111111', + expectedScope + ) + expect(rateLimits.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled() + }) + + it('maps a definite pre-provider rejection into an authoritative current snapshot', async () => { + const runtime = createRuntime() + const expectedScope = { + target: { runtime: 'host' as const, wslDistro: null }, + accountId: 'codex-account', + accountRevision: 42, + offerRevision: 'v1:stale' + } + const codex = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + const rateLimitState = { + codexTarget: expectedScope.target, + marker: 'current-after-rejection' + } + runtime.setAccountServices({ + claudeAccounts: { + listAccounts: vi.fn(() => ({ accounts: [], activeAccountId: null })) + }, + codexAccounts: { + consumeRateLimitResetCredit: vi.fn().mockResolvedValue({ + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope: expectedScope, + codex, + rateLimits: rateLimitState + }) + }, + rateLimits: {} + } as never) + + await expect( + runtime.consumeCodexRateLimitResetCredit( + '11111111-1111-4111-8111-111111111111', + expectedScope + ) + ).resolves.toMatchObject({ + status: 'rejectedBeforeProvider', + retryDisposition: 'discardAttempt', + reason: 'offerChanged', + scope: expectedScope, + snapshot: { codex, rateLimits: rateLimitState } + }) + }) + it('advertises headless browser capability when an offscreen backend backs a windowless host', () => { const runtime = createRuntime() runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) @@ -1892,7 +2010,6 @@ describe('OrcaRuntimeService', () => { expect(capabilities).toContain('browser.headless.v1') expect(capabilities).toContain('browser.certificate-trust.v1') }) - it('surfaces live offscreen load failures in headless browser snapshots', () => { const runtime = createRuntime() runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 43fce4b4fc5..ab3fc959a2b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -892,11 +892,16 @@ import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { githubAvatarIcon } from '../../shared/repo-icon' import type { ClaudeAccountService } from '../claude-accounts/service' -import type { CodexAccountService } from '../codex-accounts/service' +import type { + CodexAccountService, + CodexResetCreditRejectedBeforeProviderReason +} from '../codex-accounts/service' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { RateLimitService } from '../rate-limits/service' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' -import type { RateLimitState } from '../../shared/rate-limit-types' +import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types' +import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' import type { VoiceSettings } from '../../shared/speech-types' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' import { getCatalogModel, isLocalSpeechModel, SPEECH_MODEL_CATALOG } from '../speech/model-catalog' @@ -938,6 +943,18 @@ export type AccountsSnapshot = { rateLimits: RateLimitState } +export type CodexRateLimitResetRpcResult = { + scope: CodexResetCreditExpectedScope + snapshot: AccountsSnapshot +} & ( + | { outcome: CodexRateLimitResetOutcome } + | { + status: 'rejectedBeforeProvider' + retryDisposition: 'discardAttempt' + reason: CodexResetCreditRejectedBeforeProviderReason + } +) + type RuntimeStore = { getRepos: Store['getRepos'] getRepo: Store['getRepo'] @@ -10346,6 +10363,42 @@ export class OrcaRuntimeService { return this.requireAccountServices().codexAccounts.selectAccount(accountId) } + selectCodexAccountForTarget( + accountId: string | null, + target: CodexAccountSelectionTarget + ): Promise { + return this.requireAccountServices().codexAccounts.selectAccountForTarget(accountId, target) + } + + async consumeCodexRateLimitResetCredit( + idempotencyKey: string, + expectedScope: CodexResetCreditExpectedScope + ): Promise { + const { claudeAccounts, codexAccounts } = this.requireAccountServices() + const result = await codexAccounts.consumeRateLimitResetCredit(idempotencyKey, expectedScope) + // Why: Codex selection and usage were captured before its mutation queue + // advanced. Re-reading them here could pair scope A with queued selection B. + const snapshot = { + claude: claudeAccounts.listAccounts(), + codex: result.codex, + rateLimits: result.rateLimits + } + if ('status' in result) { + return { + status: result.status, + retryDisposition: result.retryDisposition, + reason: result.reason, + scope: result.scope, + snapshot + } + } + return { + outcome: result.outcome, + scope: result.scope, + snapshot + } + } + removeClaudeAccount(accountId: string): Promise { return this.requireAccountServices().claudeAccounts.removeAccount(accountId) } diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index ea4c80ceda0..a88e4a8b073 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -27,6 +27,96 @@ describe('account RPC methods', () => { expect(runtime.refreshAccountsForMobile).toHaveBeenCalledOnce() }) + it('forwards a client idempotency key when consuming a Codex reset credit', async () => { + const idempotencyKey = '11111111-1111-4111-8111-111111111111' + const expectedScope = { + target: { runtime: 'host' as const, wslDistro: null }, + accountId: 'codex-account', + accountRevision: 42, + offerRevision: 'v1:offer' + } + const result = { + outcome: 'reset', + scope: expectedScope, + snapshot: { claude: null, codex: null } + } + const consumeCodexRateLimitResetCredit = vi.fn().mockResolvedValue(result) + const runtime = { consumeCodexRateLimitResetCredit } as unknown as OrcaRuntimeService + const reset = method('accounts.consumeCodexResetCredit') + if (isStreamingMethod(reset)) { + throw new Error('accounts.consumeCodexResetCredit must be a request method') + } + + expect(reset.params?.parse({ idempotencyKey, expectedScope })).toEqual({ + idempotencyKey, + expectedScope + }) + expect(() => reset.params?.parse({ idempotencyKey: 'not-a-uuid', expectedScope })).toThrow() + expect(() => + reset.params?.parse({ + idempotencyKey, + expectedScope: { + ...expectedScope, + target: { runtime: 'host', wslDistro: 'Ubuntu' } + } + }) + ).toThrow() + expect(() => + reset.params?.parse({ + idempotencyKey, + expectedScope: { + ...expectedScope, + target: { runtime: 'wsl', wslDistro: null } + } + }) + ).toThrow() + expect(() => reset.params?.parse({ idempotencyKey, expectedScope, extra: true })).toThrow() + await expect(reset.handler({ idempotencyKey, expectedScope }, { runtime })).resolves.toBe( + result + ) + expect(consumeCodexRateLimitResetCredit).toHaveBeenCalledWith(idempotencyKey, expectedScope) + }) + + it('forwards the exact WSL target when selecting a Codex account', async () => { + const selectCodexAccountForTarget = vi + .fn() + .mockResolvedValue({ accounts: [], activeAccountId: null }) + const runtime = { selectCodexAccountForTarget } as unknown as OrcaRuntimeService + const select = method('accounts.selectCodexForTarget') + if (isStreamingMethod(select)) { + throw new Error('accounts.selectCodexForTarget must be a request method') + } + const params = { + accountId: null, + target: { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + } + + expect(select.params?.parse(params)).toEqual(params) + expect( + select.params?.parse({ + accountId: null, + target: { runtime: 'wsl', wslDistro: null } + }) + ).toEqual({ accountId: null, target: { runtime: 'wsl', wslDistro: null } }) + expect(() => + select.params?.parse({ + accountId: null, + target: { runtime: 'host', wslDistro: 'Ubuntu' } + }) + ).toThrow() + expect(() => + select.params?.parse({ + accountId: null, + target: { runtime: 'wsl', wslDistro: ' ' } + }) + ).toThrow() + await expect(select.handler(params, { runtime })).resolves.toEqual({ + accounts: [], + activeAccountId: null + }) + expect(selectCodexAccountForTarget).toHaveBeenCalledWith(null, params.target) + }) + it('uses a stale-aware refresh when a connection replays the subscription', async () => { const snapshot = { claude: null, codex: null } let cleanup: (() => void) | undefined diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index b1ac5426975..89dab471a75 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -7,16 +7,55 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' // registerSubscriptionCleanup's existing-key eviction path. let accountsSubscriptionSeq = 0 +const CodexResetTarget = z.discriminatedUnion('runtime', [ + z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), + // Why: reset scope must identify one exact WSL distro; null means all slots only for selection. + z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict() +]) + +const CodexSelectionTarget = z.discriminatedUnion('runtime', [ + z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), + z + .object({ + runtime: z.literal('wsl'), + // A null distro intentionally means all WSL selection slots. + wslDistro: z.string().trim().min(1).max(255).nullable() + }) + .strict() +]) + const SelectAccountParams = z.object({ accountId: z .union([z.string().min(1, 'Missing accountId'), z.null()]) .transform((v) => (v === null ? null : v)) }) +const SelectCodexAccountForTargetParams = SelectAccountParams.extend({ + target: CodexSelectionTarget +}) + const RemoveAccountParams = z.object({ accountId: z.string().min(1, 'Missing accountId') }) +const CodexResetExpectedScope = z + .object({ + target: CodexResetTarget, + accountId: z.string().min(1, 'Missing accountId').max(512), + accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + offerRevision: z.string().startsWith('v1:', 'Invalid offerRevision').max(4_096) + }) + .strict() + +const ConsumeCodexResetCreditParams = z + .object({ + // Why: the phone owns the logical attempt key so a lost response can be + // retried without spending a finite earned credit twice. + idempotencyKey: z.uuid('Invalid idempotencyKey'), + expectedScope: CodexResetExpectedScope + }) + .strict() + const AccountsUnsubscribeParams = z.object({ subscriptionId: z .unknown() @@ -52,6 +91,20 @@ export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ params: SelectAccountParams, handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId) }), + defineMethod({ + // Why: old hosts silently strip unknown target fields from selectCodex. + // A distinct RPC makes version skew fail before it can clear the host slot. + name: 'accounts.selectCodexForTarget', + params: SelectCodexAccountForTargetParams, + handler: async (params, { runtime }) => + runtime.selectCodexAccountForTarget(params.accountId, params.target) + }), + defineMethod({ + name: 'accounts.consumeCodexResetCredit', + params: ConsumeCodexResetCreditParams, + handler: async (params, { runtime }) => + runtime.consumeCodexRateLimitResetCredit(params.idempotencyKey, params.expectedScope) + }), defineMethod({ name: 'accounts.removeClaude', params: RemoveAccountParams, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 213d66deb15..e6c4363622a 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1408,6 +1408,17 @@ describe('OrcaRuntimeRpcServer', () => { const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true }) const selectClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) const selectCodexAccount = vi.fn().mockResolvedValue({ ok: true }) + const expectedCodexResetScope = { + target: { runtime: 'host' as const, wslDistro: null }, + accountId: 'codex-account', + accountRevision: 42, + offerRevision: 'v1:offer' + } + const consumeCodexRateLimitResetCredit = vi.fn().mockResolvedValue({ + outcome: 'reset', + scope: expectedCodexResetScope, + snapshot: { claude: null, codex: null } + }) const removeClaudeAccount = vi.fn().mockResolvedValue({ ok: true }) const readTerminal = vi.fn().mockResolvedValue({ tail: ['ok'] }) const getRuntimeGitStatus = vi @@ -1496,6 +1507,7 @@ describe('OrcaRuntimeRpcServer', () => { pushRuntimeGit, selectClaudeAccount, selectCodexAccount, + consumeCodexRateLimitResetCredit, removeClaudeAccount, readTerminal, getRuntimeGitStatus, @@ -2129,6 +2141,19 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_consume_codex_reset', + method: 'accounts.consumeCodexResetCredit', + deviceToken: mobile.token, + params: { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: expectedCodexResetScope + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_remove_claude', @@ -2334,6 +2359,9 @@ describe('OrcaRuntimeRpcServer', () => { ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true })) + expect(replies).toContainEqual( + expect.objectContaining({ id: 'req_consume_codex_reset', ok: true }) + ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_files_open_diff', ok: true })) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_diff', ok: true })) @@ -2365,6 +2393,10 @@ describe('OrcaRuntimeRpcServer', () => { ) expect(selectClaudeAccount).toHaveBeenCalledWith('claude-account') expect(selectCodexAccount).toHaveBeenCalledWith(null) + expect(consumeCodexRateLimitResetCredit).toHaveBeenCalledWith( + '11111111-1111-4111-8111-111111111111', + expectedCodexResetScope + ) expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined }) expect(getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1') expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 503bc9c313e..0d399aa72e8 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -134,8 +134,10 @@ function webClientPathForEndpoint(pathname: string): string { const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.list', + 'accounts.consumeCodexResetCredit', 'accounts.selectClaude', 'accounts.selectCodex', + 'accounts.selectCodexForTarget', 'accounts.subscribe', 'accounts.unsubscribe', 'aiVault.listSessions', diff --git a/src/shared/codex-reset-credit-attempt-ledger.test.ts b/src/shared/codex-reset-credit-attempt-ledger.test.ts new file mode 100644 index 00000000000..706c01f8511 --- /dev/null +++ b/src/shared/codex-reset-credit-attempt-ledger.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { + EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER, + parseCodexResetCreditAttemptLedger +} from './codex-reset-credit-attempt-ledger' + +const hostScope = { + target: { runtime: 'host' as const, wslDistro: null }, + accountId: 'account-host', + accountRevision: 42, + offerRevision: 'v1:offer-host' +} + +describe('Codex reset credit attempt ledger', () => { + it('strictly accepts pending and settled durable attempts', () => { + expect( + parseCodexResetCreditAttemptLedger({ + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: hostScope, + state: 'providerPending' + }, + { + idempotencyKey: '22222222-2222-4222-8222-222222222222', + expectedScope: { + ...hostScope, + offerRevision: 'v1:offer-settled' + }, + state: 'settled', + outcome: 'alreadyRedeemed' + } + ] + }) + ).toMatchObject({ version: 1, attempts: [{ state: 'providerPending' }, { state: 'settled' }] }) + expect(parseCodexResetCreditAttemptLedger(undefined)).toEqual( + EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER + ) + }) + + it.each([ + { + name: 'unknown field', + value: { version: 1, attempts: [], extra: true } + }, + { + name: 'duplicate idempotency key', + value: { + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: hostScope, + state: 'providerPending' + }, + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: { ...hostScope, offerRevision: 'v1:other' }, + state: 'settled', + outcome: 'reset' + } + ] + } + }, + { + name: 'duplicate claimed offer', + value: { + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: hostScope, + state: 'settled', + outcome: 'reset' + }, + { + idempotencyKey: '22222222-2222-4222-8222-222222222222', + expectedScope: hostScope, + state: 'settled', + outcome: 'alreadyRedeemed' + } + ] + } + }, + { + name: 'duplicate pending account scope', + value: { + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: hostScope, + state: 'providerPending' + }, + { + idempotencyKey: '22222222-2222-4222-8222-222222222222', + expectedScope: { ...hostScope, offerRevision: 'v1:other' }, + state: 'providerPending' + } + ] + } + }, + { + name: 'invalid WSL target', + value: { + version: 1, + attempts: [ + { + idempotencyKey: '11111111-1111-4111-8111-111111111111', + expectedScope: { + ...hostScope, + target: { runtime: 'wsl', wslDistro: null } + }, + state: 'providerPending' + } + ] + } + } + ])('rejects $name as a corrupt ledger', ({ value }) => { + expect(() => parseCodexResetCreditAttemptLedger(value)).toThrow( + 'Codex reset-credit attempt ledger is corrupt' + ) + }) +}) diff --git a/src/shared/codex-reset-credit-attempt-ledger.ts b/src/shared/codex-reset-credit-attempt-ledger.ts new file mode 100644 index 00000000000..ef1cf145b45 --- /dev/null +++ b/src/shared/codex-reset-credit-attempt-ledger.ts @@ -0,0 +1,122 @@ +import { z } from 'zod' +import type { CodexRateLimitResetOutcome } from './rate-limit-types' +import type { CodexResetCreditExpectedScope } from './codex-reset-credit-scope' + +const CodexResetCreditTargetSchema = z.discriminatedUnion('runtime', [ + z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), + z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict() +]) + +const CodexResetCreditExpectedScopeSchema = z + .object({ + target: CodexResetCreditTargetSchema, + accountId: z.string().min(1).max(512), + accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + offerRevision: z.string().startsWith('v1:').max(4_096) + }) + .strict() + +const DurableCodexResetCreditAttemptSchema = z.discriminatedUnion('state', [ + z + .object({ + idempotencyKey: z.uuid(), + expectedScope: CodexResetCreditExpectedScopeSchema, + state: z.literal('providerPending') + }) + .strict(), + z + .object({ + idempotencyKey: z.uuid(), + expectedScope: CodexResetCreditExpectedScopeSchema, + state: z.literal('settled'), + outcome: z.enum(['reset', 'nothingToReset', 'noCredit', 'alreadyRedeemed']) + }) + .strict() +]) + +const CodexResetCreditAttemptLedgerSchema = z + .object({ + version: z.literal(1), + attempts: z.array(DurableCodexResetCreditAttemptSchema).max(10_000) + }) + .strict() + .superRefine((ledger, context) => { + const keys = new Set() + const offers = new Set() + const pendingAccountScopes = new Set() + for (const [index, attempt] of ledger.attempts.entries()) { + const offerScope = JSON.stringify([ + attempt.expectedScope.target.runtime, + attempt.expectedScope.target.wslDistro, + attempt.expectedScope.accountId, + attempt.expectedScope.accountRevision, + attempt.expectedScope.offerRevision + ]) + const accountScope = JSON.stringify([ + attempt.expectedScope.target.runtime, + attempt.expectedScope.target.wslDistro, + attempt.expectedScope.accountId, + attempt.expectedScope.accountRevision + ]) + if (keys.has(attempt.idempotencyKey)) { + context.addIssue({ + code: 'custom', + message: 'Duplicate idempotency key', + path: ['attempts', index, 'idempotencyKey'] + }) + } + if (offers.has(offerScope)) { + context.addIssue({ + code: 'custom', + message: 'Duplicate claimed offer', + path: ['attempts', index, 'expectedScope'] + }) + } + if (attempt.state === 'providerPending' && pendingAccountScopes.has(accountScope)) { + context.addIssue({ + code: 'custom', + message: 'Duplicate pending account scope', + path: ['attempts', index, 'expectedScope'] + }) + } + keys.add(attempt.idempotencyKey) + offers.add(offerScope) + if (attempt.state === 'providerPending') { + pendingAccountScopes.add(accountScope) + } + } + }) + +export type DurableCodexResetCreditAttempt = + | { + idempotencyKey: string + expectedScope: CodexResetCreditExpectedScope + state: 'providerPending' + } + | { + idempotencyKey: string + expectedScope: CodexResetCreditExpectedScope + state: 'settled' + outcome: CodexRateLimitResetOutcome + } + +export type CodexResetCreditAttemptLedger = { + version: 1 + attempts: DurableCodexResetCreditAttempt[] +} + +export const EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER: CodexResetCreditAttemptLedger = { + version: 1, + attempts: [] +} + +export function parseCodexResetCreditAttemptLedger(value: unknown): CodexResetCreditAttemptLedger { + if (value === undefined) { + return { version: 1, attempts: [] } + } + const parsed = CodexResetCreditAttemptLedgerSchema.safeParse(value) + if (!parsed.success) { + throw new Error('Codex reset-credit attempt ledger is corrupt') + } + return structuredClone(parsed.data) as CodexResetCreditAttemptLedger +} diff --git a/src/shared/codex-reset-credit-scope.test.ts b/src/shared/codex-reset-credit-scope.test.ts new file mode 100644 index 00000000000..0852612a8c6 --- /dev/null +++ b/src/shared/codex-reset-credit-scope.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import type { ProviderRateLimits } from './rate-limit-types' +import type { CodexManagedAccountSummary } from './types' +import { buildCodexResetCreditExpectedScope } from './codex-reset-credit-scope' + +const account: CodexManagedAccountSummary = { + id: 'account-host', + email: 'dev@example.com', + managedHomeRuntime: 'host', + wslDistro: null, + createdAt: 10, + updatedAt: 20, + lastAuthenticatedAt: 20 +} + +const limits: ProviderRateLimits = { + provider: 'codex', + session: { + usedPercent: 100, + windowMinutes: 300, + resetsAt: 1_000, + resetDescription: 'soon' + }, + weekly: null, + rateLimitResetCredits: { + availableCount: 1, + totalEarnedCount: 2, + nextExpiresAt: 2_000, + credits: [{ status: 'available', expiresAt: 2_000, grantedAt: 500 }] + }, + updatedAt: 30, + error: null, + status: 'ok' +} + +describe('buildCodexResetCreditExpectedScope', () => { + it('builds a deterministic exact host scope', () => { + const first = buildCodexResetCreditExpectedScope({ + target: { runtime: 'host', wslDistro: null }, + account, + limits + }) + const second = buildCodexResetCreditExpectedScope({ + target: { runtime: 'host', wslDistro: null }, + account, + limits: { + ...limits, + rateLimitResetCredits: { + ...limits.rateLimitResetCredits!, + credits: [ + { status: 'available', expiresAt: 3_000, grantedAt: 600 }, + ...limits.rateLimitResetCredits!.credits! + ].toReversed() + } + } + }) + + const firstWithBothRows = buildCodexResetCreditExpectedScope({ + target: { runtime: 'host', wslDistro: null }, + account, + limits: { + ...limits, + rateLimitResetCredits: { + ...limits.rateLimitResetCredits!, + credits: [ + ...limits.rateLimitResetCredits!.credits!, + { status: 'available', expiresAt: 3_000, grantedAt: 600 } + ] + } + } + }) + + expect(firstWithBothRows).toEqual(second) + expect(first).toMatchObject({ + target: { runtime: 'host', wslDistro: null }, + accountId: account.id, + accountRevision: account.updatedAt, + offerRevision: expect.stringMatching(/^v1:/) + }) + }) + + it('changes the offer revision when fetched credit or window data changes', () => { + const original = buildCodexResetCreditExpectedScope({ + target: { runtime: 'host', wslDistro: null }, + account, + limits + }) + const refreshed = buildCodexResetCreditExpectedScope({ + target: { runtime: 'host', wslDistro: null }, + account, + limits: { ...limits, updatedAt: limits.updatedAt + 1 } + }) + + expect(refreshed?.offerRevision).not.toBe(original?.offerRevision) + }) + + it.each([ + { + name: 'system default account', + target: { runtime: 'host', wslDistro: null } as const, + candidate: null, + candidateLimits: limits + }, + { + name: 'no available credit', + target: { runtime: 'host', wslDistro: null } as const, + candidate: account, + candidateLimits: { + ...limits, + rateLimitResetCredits: { ...limits.rateLimitResetCredits!, availableCount: 0 } + } + }, + { + name: 'unknown WSL distro', + target: { runtime: 'wsl', wslDistro: null } as const, + candidate: { ...account, managedHomeRuntime: 'wsl' as const, wslDistro: 'Ubuntu' }, + candidateLimits: limits + }, + { + name: 'runtime mismatch', + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } as const, + candidate: account, + candidateLimits: limits + }, + { + name: 'WSL distro mismatch', + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } as const, + candidate: { ...account, managedHomeRuntime: 'wsl' as const, wslDistro: 'Debian' }, + candidateLimits: limits + } + ])('fails closed for $name', ({ target, candidate, candidateLimits }) => { + expect( + buildCodexResetCreditExpectedScope({ + target, + account: candidate, + limits: candidateLimits + }) + ).toBeNull() + }) +}) diff --git a/src/shared/codex-reset-credit-scope.ts b/src/shared/codex-reset-credit-scope.ts new file mode 100644 index 00000000000..aec85c73117 --- /dev/null +++ b/src/shared/codex-reset-credit-scope.ts @@ -0,0 +1,108 @@ +import type { + ProviderRateLimits, + RateLimitRuntimeTarget, + RateLimitWindow +} from './rate-limit-types' +import type { CodexManagedAccountSummary } from './types' + +export type CodexResetCreditExpectedScope = { + target: RateLimitRuntimeTarget + accountId: string + accountRevision: number + offerRevision: string +} + +type BuildCodexResetCreditExpectedScopeOptions = { + target: RateLimitRuntimeTarget + account: Pick< + CodexManagedAccountSummary, + 'id' | 'managedHomeRuntime' | 'wslDistro' | 'updatedAt' + > | null + limits: ProviderRateLimits | null +} + +function windowRevision(window: RateLimitWindow | null): readonly unknown[] | null { + if (!window) { + return null + } + return [window.usedPercent, window.windowMinutes, window.resetsAt] +} + +function buildOfferRevision(limits: ProviderRateLimits): string { + const credits = limits.rateLimitResetCredits + const creditRows = [...(credits?.credits ?? [])] + .map((credit) => [credit.status, credit.expiresAt, credit.grantedAt] as const) + .sort((left, right) => { + const leftKey = JSON.stringify(left) + const rightKey = JSON.stringify(right) + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0 + }) + + // Why: clients treat this as an opaque compare-and-swap token. Including the + // fetched-at revision makes a refresh invalidate a confirmation based on old quota data. + return `v1:${JSON.stringify([ + credits?.availableCount ?? 0, + credits?.totalEarnedCount ?? null, + credits?.nextExpiresAt ?? null, + creditRows, + windowRevision(limits.session), + windowRevision(limits.weekly), + limits.updatedAt + ])}` +} + +export function buildCodexResetCreditExpectedScope({ + target, + account, + limits +}: BuildCodexResetCreditExpectedScopeOptions): CodexResetCreditExpectedScope | null { + if (!account || limits?.provider !== 'codex') { + return null + } + if ((limits.rateLimitResetCredits?.availableCount ?? 0) <= 0) { + return null + } + if ( + !account.id.trim() || + account.id.length > 512 || + !Number.isSafeInteger(account.updatedAt) || + account.updatedAt < 0 || + !Number.isSafeInteger(limits.updatedAt) || + limits.updatedAt < 0 + ) { + return null + } + + const accountRuntime = account.managedHomeRuntime ?? 'host' + if (target.runtime === 'host') { + if (target.wslDistro !== null || accountRuntime !== 'host') { + return null + } + } else { + const targetDistro = target.wslDistro?.trim() + const accountDistro = account.wslDistro?.trim() + if ( + !targetDistro || + targetDistro.length > 255 || + accountRuntime !== 'wsl' || + accountDistro !== targetDistro + ) { + return null + } + } + + const offerRevision = buildOfferRevision(limits) + if (offerRevision.length > 4_096) { + return null + } + + return { + target: { + runtime: target.runtime, + wslDistro: target.runtime === 'wsl' ? target.wslDistro!.trim() : null + }, + accountId: account.id, + accountRevision: account.updatedAt, + offerRevision + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 313028e22f0..13c94faa9c5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -10,6 +10,7 @@ import type { WorkspaceSessionState, AgentActivityDisplayMode } from './types' +import { EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER } from './codex-reset-credit-attempt-ledger' import { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts' import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' @@ -425,7 +426,8 @@ export function getDefaultPersistedState(homedir: string): PersistedState { automations: [], automationRuns: [], onboarding: getDefaultOnboardingState(), - featureInteractionTelemetryBuckets: {} + featureInteractionTelemetryBuckets: {}, + codexResetCreditAttemptLedger: structuredClone(EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER) } } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d2ade5474bb..eeff9fefd49 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -53,6 +53,7 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman // replay ambiguous cutovers when the host advertises idempotent create support. export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'worktree.create-idempotency.v1' as const +export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const @@ -88,7 +89,8 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, - FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY + FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + CODEX_RESET_CREDIT_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/types.ts b/src/shared/types.ts index 5cae3928335..7a8253a0e85 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -43,6 +43,7 @@ import type { import type { UsagePercentageDisplay } from './usage-percentage-display' import type { StatusBarUsageMode } from './status-bar-usage-mode' import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' +import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -3486,6 +3487,8 @@ export type PersistedState = { onboarding: OnboardingState /** Main-owned telemetry de-dupe marker; never exposed through PersistedUIState. */ featureInteractionTelemetryBuckets?: FeatureInteractionTelemetryBucketState + /** Main-owned reset mutation journal. Never expose this through renderer settings APIs. */ + codexResetCreditAttemptLedger?: CodexResetCreditAttemptLedger } // ─── Filesystem ─────────────────────────────────────────────