feat(mobile): add safe Codex rate-limit resets (#9394)

* feat(mobile): add safe Codex rate-limit resets

* fix(mobile): address reset credit review feedback

* review: purge removed-account reset attempts, shared capability constant, rebase test mocks

* review: preserve host compatibility and reset durability

* fix(mobile): recover reset capability after cutover

* fix(mobile): validate runtime capability payloads

* fix(mobile): enforce capability payload contract

* fix(mobile): route mock terminals to selected worktree

* test(mobile): pin malformed probe retry behavior

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
kazu-42
2026-07-24 14:42:04 -07:00
committed by GitHub
co-authored by Brennan Benson
parent 402b5a1cc8
commit 4a71a0ecb2
49 changed files with 5920 additions and 122 deletions
+87 -18
View File
@@ -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<string | null>(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 (
<View style={styles.section}>
@@ -160,7 +215,7 @@ export default function AccountsScreen() {
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => selectAccount(provider, null)}
disabled={busyAccountId !== null || connState !== 'connected'}
disabled={busyAccountId !== null || resettingCodex || connState !== 'connected'}
>
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>System default</Text>
@@ -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) ? (
<View style={styles.usageRow}>
<UsageBar
label="5h"
@@ -188,7 +243,7 @@ export default function AccountsScreen() {
) : null}
</View>
<View style={styles.rowTrailing}>
{state.activeAccountId === null ? (
{activeAccountId === null ? (
<Check size={16} color={colors.accentBlue} />
) : busyAccountId === `${provider}:default` ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
@@ -197,7 +252,7 @@ export default function AccountsScreen() {
</Pressable>
{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() {
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => selectAccount(provider, account.id)}
disabled={busyAccountId !== null || connState !== 'connected' || isActive}
disabled={
busyAccountId !== null ||
resettingCodex ||
connState !== 'connected' ||
isActive
}
>
<View style={styles.rowMain}>
<Text style={styles.rowTitle} numberOfLines={1}>
@@ -252,6 +312,15 @@ export default function AccountsScreen() {
</View>
)
})}
{resetCredit && codexResetSupported && resetScope && connState === 'connected' ? (
<CodexResetCreditAction
summary={resetCredit}
scopeLabel={resetScopeLabel}
busy={resettingCodex}
disabled={resettingCodex || busyAccountId !== null || connState !== 'connected'}
onPress={confirmCodexReset}
/>
) : null}
</View>
</View>
)
+11 -4
View File
@@ -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.
}
}
})
}
+90
View File
@@ -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<string, { requestId: string; respond: Respond }>()
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
}
}
+243
View File
@@ -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<string, MockCodexUsage>()
let resetOperations = new Map<string, { scopeKey: string; outcome: 'reset' | 'noCredit' }>()
let resetOfferOwners = new Map<string, string>()
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
}))
}
}
}
+22 -21
View File
@@ -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 }))
@@ -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
}
]
}
@@ -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<ReactTestRenderer> {
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<void> {
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<string, string>
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())
})
})
+1
View File
@@ -14,6 +14,7 @@ export type {
UsageBarState
} from './account-usage-state'
export {
decodeAccountsSnapshot,
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
@@ -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())
})
})
@@ -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 (
<>
<View style={styles.separator} />
<View style={styles.row}>
<View style={styles.copy}>
<Text style={styles.title}>{summary.availabilityLabel}</Text>
<Text style={styles.subtitle}>
{[summary.expiryLabel, scopeLabel].filter(Boolean).join(' · ') ||
'Earned Codex rate-limit reset'}
</Text>
</View>
<Pressable
style={({ pressed }) => [
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 ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<RotateCcw size={14} color={colors.textPrimary} />
)}
<Text style={styles.buttonText}>{busy ? 'Resetting…' : 'Use reset'}</Text>
</Pressable>
</View>
</>
)
}
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
}
})
@@ -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 ?? []
}
+18 -47
View File
@@ -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<RateLimitWindow & { name: string }>
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'
@@ -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<string, unknown>)[segment]
}
if (!current || typeof current !== 'object' || Array.isArray(current)) {
throw new Error(`Invalid fixture path: ${path.join('.')}`)
}
const record = current as Record<string, unknown>
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')
})
})
+236
View File
@@ -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<typeof RateLimitWindowSchema>
export type ProviderRateLimits = z.infer<typeof ProviderRateLimitsSchema>
export type InactiveAccountUsage = z.infer<typeof InactiveAccountUsageSchema>
export type RateLimitRuntimeTarget = z.infer<typeof RateLimitRuntimeTargetSchema>
export type ClaudeAccountSummary = z.infer<typeof ClaudeAccountSummarySchema>
export type CodexAccountSummary = z.infer<typeof CodexAccountSummarySchema>
export type AccountsSnapshot = z.infer<typeof AccountsSnapshotSchema>
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
}
@@ -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()
})
})
@@ -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<RpcClient, 'sendRequest'>
): Promise<boolean> {
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
}
@@ -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<string, string | null>
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<string, string>
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<void>((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()
})
})
+282
View File
@@ -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<string, Promise<CodexResetCreditRequestResult>>()
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<string, unknown>
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<RpcClient, 'sendRequest'>,
options: {
hostId: string
expectedScope: CodexResetCreditExpectedScope
createIdempotencyKey: () => string
}
): Promise<CodexResetCreditRequestResult> {
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<RpcClient, 'sendRequest'>,
options: {
hostId: string
expectedScope: CodexResetCreditExpectedScope
createIdempotencyKey: () => string
}
): Promise<CodexResetCreditRequestResult> {
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()
}
@@ -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 }
}
@@ -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
})
})
})
@@ -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<string, unknown>): 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])
})
})
@@ -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> = {}
): 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<string, string>
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<void>((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)
})
})
@@ -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<typeof CodexResetAttemptSchema>
type AttemptIdentity = {
hostId: string
expectedScope: CodexResetCreditExpectedScope
}
const scopeMutations = new Map<string, Promise<void>>()
// 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<T>(
identity: AttemptIdentity,
action: () => Promise<T>
): Promise<T> {
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<CodexResetAttempt> {
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<void> {
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()
}
@@ -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[])[] = []
@@ -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) {
File diff suppressed because it is too large Load Diff
+480
View File
@@ -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<CodexResetCreditConsumeResult> | 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<CodexResetCreditExpectedScope, 'target' | 'accountId' | 'accountRevision'>
): 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<unknown> = Promise.resolve()
private readonly resetAttemptsByKey = new Map<string, CodexResetCreditAttempt>()
private readonly resetAttemptKeyByOffer = new Map<string, string>()
private readonly unresolvedResetKeyByAccountScope = new Map<string, string>()
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<CodexResetCreditConsumeResult> {
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<CodexRateLimitResetResult> {
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<CodexResetCreditConsumeResult> {
const promise = this.serializeMutation(async (): Promise<CodexResetCreditConsumeResult> => {
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
+37 -7
View File
@@ -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<typeof vi.fn>
refreshGrok: ReturnType<typeof vi.fn>
consumeCodexRateLimitResetCredit: ReturnType<typeof vi.fn>
} {
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()
})
})
+7 -2
View File
@@ -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)
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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)
+69
View File
@@ -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'
+27
View File
@@ -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[] {
+127 -1
View File
@@ -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<ProviderRateLimits>()
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<ProviderRateLimits>()
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 =
+71 -10
View File
@@ -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<CodexRateLimitResetResult> {
const codexTarget = this.codexFetchTarget
const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
async consumeCodexRateLimitResetCredit(options: {
idempotencyKey: string
target: RateLimitRuntimeTarget
codexHomePath: string | null
}): Promise<CodexRateLimitResetResult> {
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<RateLimitState> {
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'
@@ -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',
+118 -1
View File
@@ -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() })
+55 -2
View File
@@ -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<CodexRateLimitAccountsState> {
return this.requireAccountServices().codexAccounts.selectAccountForTarget(accountId, target)
}
async consumeCodexRateLimitResetCredit(
idempotencyKey: string,
expectedScope: CodexResetCreditExpectedScope
): Promise<CodexRateLimitResetRpcResult> {
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<ClaudeRateLimitAccountsState> {
return this.requireAccountServices().claudeAccounts.removeAccount(accountId)
}
@@ -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
+53
View File
@@ -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,
+32
View File
@@ -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<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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)
+2
View File
@@ -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',
@@ -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'
)
})
})
@@ -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<string>()
const offers = new Set<string>()
const pendingAccountScopes = new Set<string>()
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
}
+140
View File
@@ -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()
})
})
+108
View File
@@ -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
}
}
+3 -1
View File
@@ -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)
}
}
+3 -1
View File
@@ -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 & {})
+3
View File
@@ -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 ─────────────────────────────────────────────