Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions src/services/api/issue107.quota-per-account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Regression test for issue #107 (CLI half) — explicit account selection
* must win over env credentials, and the usage envelope must include the
* duration of every window the provider reports.
*
* Repro pattern from the field RCA:
* - 2 Codex accounts stored in secure storage with distinct tokens and
* provider-account-ids.
* - CODEX_HOME points at a third credential file.
* - `provider-accounts usage --provider codex --account <id>` today
* returns the env credential and only the 10080-min window, so the
* app receives one identical snapshot for both accounts.
*
* Spec from frontend-issue107-2026-08-28.md (validated by review-issue107):
* 1. Explicit account selection uses the secure-storage token; envs do
* not substitute.
* 2. Envelope returns ALL provider-reported windows (primary + secondary),
* including arbitrary durations.
* 3. Each window carries `windowMinutes?: positive integer` (additive).
*/
import { afterEach, expect, mock, test } from 'bun:test'

import { resolveRuntimeCodexCredentials } from './providerConfig.js'
import {
normalizeCodexProviderUsage,
normalizeClaudeProviderUsage,
} from './providerUsageProtocol.js'

afterEach(() => {
mock.restore()
})

// ─── Bug 1 — env credentials must NOT substitute explicit account selection ─

test('env CODEX_HOME does NOT override a stored credential passed for explicit account selection', () => {
const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_HOME: '/env/path/that/should/not/win',
CODEX_ACCOUNT_ID: 'acct_env_account',
} as NodeJS.ProcessEnv,
storedCredentials: {
apiKey: 'stored-selected-api-key',
accessToken: 'stored-selected-access-token',
accountId: 'acct_selected',
},
})

expect(credentials.source).toBe('secure-storage')
expect(credentials.accountId).toBe('acct_selected')
expect(credentials.apiKey).toBe('stored-selected-api-key')
})

test('env CODEX_AUTH_JSON_PATH does NOT override a stored credential passed for explicit account selection', () => {
const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_AUTH_JSON_PATH: '/env/auth.json/that/should/not/win',
CODEX_ACCOUNT_ID: 'acct_env_account',
} as NodeJS.ProcessEnv,
storedCredentials: {
apiKey: 'stored-selected-api-key',
accessToken: 'stored-selected-access-token',
accountId: 'acct_selected',
},
})

expect(credentials.source).toBe('secure-storage')
expect(credentials.accountId).toBe('acct_selected')
})

test('localAccountId parameter pulls from secure storage even when env credentials are present', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: (_localAccountId?: string) => ({
accessToken: 'selected-token-from-secure-storage',
accountId: 'acct_from_secure_storage',
}),
}))

// Cache-busting query string so Bun re-imports providerConfig after the
// mock.module above changes which credentials function resolves.
const { resolveRuntimeCodexCredentials } = await import(
// @ts-expect-error cache-busting query string for Bun module mocks
'./providerConfig.js?red-107-local-account-vs-env'
)

const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_HOME: '/env/path/that/should/not/win',
CODEX_ACCOUNT_ID: 'acct_env_account',
} as NodeJS.ProcessEnv,
localAccountId: 'local-selected',
})
expect(credentials.source).toBe('secure-storage')
expect(credentials.accountId).toBe('acct_from_secure_storage')
expect(credentials.apiKey).toBe('selected-token-from-secure-storage')
})

// ─── Bug 2 — usage envelope must include every provider-reported window ──

test('Codex envelope surfaces BOTH primary and secondary windows with their reported durations', () => {
const snapshot = normalizeCodexProviderUsage('local-plus', {
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 11,
limit_window_seconds: 180 * 60,
reset_at: 1_775_685_041,
},
secondary_window: {
used_percent: 73,
limit_window_seconds: 480 * 60,
reset_at: 1_775_771_441,
},
},
})

const primary = snapshot.windows.find(w => w.id === 'codex:codex:primary')
const secondary = snapshot.windows.find(w => w.id === 'codex:codex:secondary')
expect(primary).toBeDefined()
expect(primary?.usedPercent).toBe(11)
expect(primary?.windowMinutes).toBe(180)
expect(secondary).toBeDefined()
expect(secondary?.usedPercent).toBe(73)
expect(secondary?.windowMinutes).toBe(480)
})

test('Codex envelope surfaces arbitrary durations (not only 10080)', () => {
const snapshot = normalizeCodexProviderUsage('local-plus', {
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 30,
limit_window_seconds: 300 * 60,
},
secondary_window: {
used_percent: 42,
limit_window_seconds: 4320 * 60,
},
},
})

const weekly = snapshot.windows.find(w => w.kind === 'weekly')
expect(weekly).toBeDefined()
expect(weekly?.usedPercent).toBe(42)
expect(weekly?.windowMinutes).toBe(4320)
})

test('Claude envelope surfaces scoped windows with arbitrary durations', () => {
const snapshot = normalizeClaudeProviderUsage(
'local-claude',
{ id: 'pro', displayName: 'Pro' },
{
five_hour: { utilization: 8, resets_at: '2026-08-10T16:00:00.000Z' },
seven_day: { utilization: 5, resets_at: '2026-08-16T21:00:00.000Z' },
scoped_limits: [
{
id: 'fable',
utilization: 9,
resets_at: '2026-08-16T21:00:00.000Z',
windowMinutes: 1440,
modelScope: 'fable',
},
{
id: 'sora',
utilization: 12,
resets_at: '2026-08-12T12:00:00.000Z',
windowMinutes: 4320,
modelScope: 'sora',
},
],
},
)

const weeklyScopeds = snapshot.windows.filter(
w => w.kind === 'model-scoped-weekly',
)
const fableWindow = weeklyScopeds.find(w => w.modelScope === 'fable')
const soraWindow = weeklyScopeds.find(w => w.modelScope === 'sora')
expect(fableWindow).toBeDefined()
expect(fableWindow?.usedPercent).toBe(9)
expect(fableWindow?.windowMinutes).toBe(1440)
expect(soraWindow).toBeDefined()
expect(soraWindow?.usedPercent).toBe(12)
expect(soraWindow?.windowMinutes).toBe(4320)
})
26 changes: 18 additions & 8 deletions src/services/api/providerConfig.runtimeCodexCredentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ function makeJwt(payload: Record<string, unknown>): string {
return `${header}.${body}.signature`
}

test('runtime credential resolution honors explicit auth.json over stored secure-storage tokens', () => {
test('runtime credential resolution prefers stored credentials over an explicit auth.json path', () => {
// Spec update (issue #107): when the caller passes storedCredentials,
// the explicit account selection wins over env credentials (including
// CODEX_AUTH_JSON_PATH pointing at a valid auth.json file). The legacy
// behaviour of returning source='auth.json' in this scenario has been
// inverted — the stored credential is authoritative.
const tempDir = mkdtempSync(join(tmpdir(), 'verboo-codex-explicit-auth-'))
const authPath = join(tempDir, 'auth.json')

Expand Down Expand Up @@ -44,15 +49,20 @@ test('runtime credential resolution honors explicit auth.json over stored secure
},
})

expect(credentials.source).toBe('auth.json')
expect(credentials.accountId).toBe('acct_explicit_auth_json')
expect(credentials.apiKey).not.toBe('stored-api-key')
expect(credentials.source).toBe('secure-storage')
expect(credentials.accountId).toBe('acct_stored')
expect(credentials.apiKey).toBe('stored-api-key')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})

test('runtime credential resolution preserves an explicit auth.json path even when it is missing', () => {
test('runtime credential resolution prefers stored credentials over an explicit auth.json path (even when missing)', () => {
// Spec update (issue #107): explicit account selection — caller passes
// storedCredentials — must win over env credentials (CODEX_HOME /
// CODEX_AUTH_JSON_PATH), even when the env path is missing. The legacy
// behaviour of returning source='none' with the env path preserved has
// been replaced: the stored credential is authoritative.
const tempDir = mkdtempSync(join(tmpdir(), 'verboo-codex-missing-auth-'))
const authPath = join(tempDir, 'missing-auth.json')

Expand All @@ -68,9 +78,9 @@ test('runtime credential resolution preserves an explicit auth.json path even wh
},
})

expect(credentials.source).toBe('none')
expect(credentials.authPath).toBe(authPath)
expect(credentials.apiKey).toBe('')
expect(credentials.source).toBe('secure-storage')
expect(credentials.accountId).toBe('acct_stored')
expect(credentials.apiKey).toBe('stored-api-key')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
Expand Down
64 changes: 41 additions & 23 deletions src/services/api/providerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -875,17 +875,30 @@ export function resolveStoredCodexCredentials(options: {
CodexCredentialBlob,
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
>
/**
* Optional override for the secure-storage accountId, kept for callers
* that want to apply an env-side account-id on top of a stored token
* (e.g. legacy callers without an explicit per-account selection).
* When the caller passes an explicit account selection (stored
* credentials with their own accountId), the stored accountId MUST
* win — env hints are not authoritative for explicit selection.
*/
envAccountId?: string
/** When true, envAccountId is ignored and only the stored accountId is used. */
preferStoredAccountId?: boolean
}): ResolvedCodexCredentials {
const { storedCredentials, envAccountId } = options
const { storedCredentials, envAccountId, preferStoredAccountId } = options

const storedAccountId =
storedCredentials.accountId ??
parseChatgptAccountId(storedCredentials.idToken) ??
parseChatgptAccountId(storedCredentials.accessToken)
return {
apiKey: storedCredentials.apiKey ?? storedCredentials.accessToken,
accountId:
envAccountId ??
storedCredentials.accountId ??
parseChatgptAccountId(storedCredentials.idToken) ??
parseChatgptAccountId(storedCredentials.accessToken),
preferStoredAccountId
? (storedAccountId ?? envAccountId ?? '')
: (envAccountId ?? storedAccountId ?? ''),
source: 'secure-storage',
}
}
Expand Down Expand Up @@ -939,42 +952,47 @@ export function resolveRuntimeCodexCredentials(options?: {
>
}): ResolvedCodexCredentials {
const env = options?.env ?? process.env
// Explicit account selection (caller-passed storedCredentials or a
// localAccountId that maps to a secure-storage record) ALWAYS wins
// over env credentials. The protocol-level `provider-accounts usage
// --account <id>` path passes localAccountId or storedCredentials and
// must not silently fall back to CODEX_HOME / CODEX_AUTH_JSON_PATH /
// CODEX_API_KEY / CODEX_ACCOUNT_ID / CHATGPT_ACCOUNT_ID — those envs
// remain authoritative only when no explicit selection was provided.
const selectedStoredCredentials =
options?.storedCredentials ??
(options?.localAccountId
? readCodexCredentials(options.localAccountId)
: undefined)
const explicitCredentials = resolveEnvOrAuthJsonCodexCredentials(env, {
explicitAuthPathOnly: true,
})
const explicitAuthPathConfigured = Boolean(
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
)
const hasStoredCredentialsOption = Boolean(
const hasExplicitSelection = Boolean(
options &&
(Object.prototype.hasOwnProperty.call(options, 'storedCredentials') ||
options.localAccountId),
)

if (
explicitAuthPathConfigured ||
explicitCredentials.source === 'env' ||
explicitCredentials.source === 'auth.json'
) {
return explicitCredentials
}

if (selectedStoredCredentials?.accessToken) {
if (hasExplicitSelection && selectedStoredCredentials?.accessToken) {
return resolveStoredCodexCredentials({
storedCredentials: selectedStoredCredentials,
envAccountId:
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
asTrimmedString(env.CHATGPT_ACCOUNT_ID),
preferStoredAccountId: true,
})
}

if (hasStoredCredentialsOption) {
return resolveEnvOrAuthJsonCodexCredentials(env)
const explicitCredentials = resolveEnvOrAuthJsonCodexCredentials(env, {
explicitAuthPathOnly: true,
})
const explicitAuthPathConfigured = Boolean(
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
)

if (
explicitAuthPathConfigured ||
explicitCredentials.source === 'env' ||
explicitCredentials.source === 'auth.json'
) {
return explicitCredentials
}

return resolveCodexApiCredentials(env)
Expand Down
16 changes: 14 additions & 2 deletions src/services/api/providerUsageProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,25 @@ test('Codex Plus keeps only its provider-reported base weekly window', () => {
})

expect(snapshot.plan).toEqual({ id: 'plus', displayName: 'Plus' })
// Spec update (issue #107): the protocol now surfaces BOTH primary and
// secondary windows from the provider payload, with their reported
// durations attached as windowMinutes. Legacy 300/10080 hardcoding is
// gone — durations are provider-driven.
expect(snapshot.windows).toEqual([
{
id: 'codex:secondary',
id: 'codex:codex:primary',
kind: 'session',
displayLabel: 'Codex Session',
usedPercent: 38,
windowMinutes: 300,
},
{
id: 'codex:codex:secondary',
kind: 'weekly',
displayLabel: 'Weekly',
displayLabel: 'Codex Weekly',
usedPercent: 32,
resetsAt: '2026-04-08T21:50:41.000Z',
windowMinutes: 10080,
},
])
})
Expand Down
Loading
Loading