From 9edff347e990f039255bde07c85a92ea415d1438 Mon Sep 17 00:00:00 2001 From: Can Date: Sun, 16 Aug 2026 02:42:26 +0300 Subject: [PATCH 1/2] feat: lock upstream wire contract and harden auth, refresh, and consume idempotency Verify codex-reset against current openai/codex (analyzed 2026-07-13..2026-08-16) by pinning the wire contract in tests instead of hand-copied constants, and fix the auth/routing/retry defects an adversarial review of that analysis exposed. Contract lock: - injectable HTTP transport (core/http.ts) + CODEX_RESET_BASE_URL override; production path unchanged and now regression-tested against a live socket - tools/extract-upstream-manifest.mjs generates the semantic manifest from 11 upstream Rust files; test/upstream-contract.test.ts asserts endpoints, consume request/response, snake_case codes, window fields, plan display names, JWT claims, auth-file schema, headers, refresh and whoami contracts against it and live-diffs a re-extraction when a checkout is present Defect hardening: - idempotent consume: persist redeem_request_id before the POST, reuse it across invocations on ambiguous outcomes (timeout / malformed 2xx / 5xx), warn when a different credit or a >24h record forces a fresh key (core/idempotency.ts) - FedRAMP: read chatgpt_account_is_fedramp, send X-OpenAI-Fedramp: true - PAT accounts: hydrate via the upstream whoami endpoint instead of dropping - token refresh: proactive (exp claim) + reactive (401 -> refresh -> retry once), rotation persisted atomically, upstream-parity failure messages surfaced - account-id precedence matches upstream (tokens.account_id -> claim -> orgs) - plan labels from KnownPlan::display_name() (ent26 -> Enterprise, etc.) - error matrix: 403/429+Retry-After/5xx/HTML/empty/oversized/network messages - auth-mode matrix: apikey/agentIdentity/bedrock/token-less files skipped with warnings instead of silently; organizations + profile.email fallbacks Tests: 52 -> 123, all green; production transport covered end to end. --- eslint.config.js | 10 + package.json | 5 +- src/commands/list.ts | 4 +- src/commands/reset.ts | 49 +++- src/core/accounts.ts | 196 +++++++++++--- src/core/api.ts | 252 +++++++++++++----- src/core/auth.ts | 164 ++++++++++++ src/core/http.ts | 104 ++++++++ src/core/idempotency.ts | 101 ++++++++ src/core/jwt.ts | 16 ++ src/core/types.ts | 35 ++- src/index.ts | 31 ++- src/utils/format.ts | 51 ++-- test/accounts.test.ts | 262 ++++++++++++++++++- test/api-boundary.test.ts | 366 +++++++++++++++++++++++++++ test/api.test.ts | 3 + test/e2e-helpers.ts | 124 +++++++++ test/e2e-weekly.test.ts | 177 +++++++++++++ test/fixtures/upstream-manifest.json | 244 ++++++++++++++++++ test/format.test.ts | 14 + test/helpers.ts | 175 +++++++++++++ test/http-transport.test.ts | 119 +++++++++ test/idempotency.test.ts | 112 ++++++++ test/upstream-contract.test.ts | 325 ++++++++++++++++++++++++ tools/extract-upstream-manifest.mjs | 303 ++++++++++++++++++++++ 25 files changed, 3082 insertions(+), 160 deletions(-) create mode 100644 src/core/auth.ts create mode 100644 src/core/http.ts create mode 100644 src/core/idempotency.ts create mode 100644 src/core/jwt.ts create mode 100644 test/api-boundary.test.ts create mode 100644 test/e2e-helpers.ts create mode 100644 test/e2e-weekly.test.ts create mode 100644 test/fixtures/upstream-manifest.json create mode 100644 test/helpers.ts create mode 100644 test/http-transport.test.ts create mode 100644 test/idempotency.test.ts create mode 100644 test/upstream-contract.test.ts create mode 100644 tools/extract-upstream-manifest.mjs diff --git a/eslint.config.js b/eslint.config.js index f14a001..ff89321 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,6 +9,16 @@ export default tseslint.config( { ignores: ['dist/', 'node_modules/', 'bin/codex-reset.js'], }, + { + files: ['tools/**/*.mjs'], + languageOptions: { + globals: { + process: 'readonly', + console: 'readonly', + URL: 'readonly', + }, + }, + }, { rules: { '@typescript-eslint/no-unused-vars': [ diff --git a/package.json b/package.json index 8915995..237ba58 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,9 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit", - "test": "node --import tsx --test test/format.test.ts test/accounts.test.ts test/cli.test.ts test/api.test.ts test/reset-safety.test.ts", - "test:coverage": "node --import tsx --test --experimental-test-coverage test/format.test.ts test/accounts.test.ts test/cli.test.ts test/api.test.ts test/reset-safety.test.ts", + "test": "node --import tsx --test test/format.test.ts test/accounts.test.ts test/cli.test.ts test/api.test.ts test/reset-safety.test.ts test/api-boundary.test.ts test/http-transport.test.ts test/idempotency.test.ts test/e2e-weekly.test.ts test/upstream-contract.test.ts", + "test:coverage": "node --import tsx --test --experimental-test-coverage test/format.test.ts test/accounts.test.ts test/cli.test.ts test/api.test.ts test/reset-safety.test.ts test/api-boundary.test.ts test/http-transport.test.ts test/idempotency.test.ts test/e2e-weekly.test.ts test/upstream-contract.test.ts", + "manifest": "node tools/extract-upstream-manifest.mjs --out test/fixtures/upstream-manifest.json", "prepack": "npm run build && npm run typecheck && npm run lint", "smoke": "npm pack --dry-run" }, diff --git a/src/commands/list.ts b/src/commands/list.ts index e1883a8..a7d68fe 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -127,7 +127,7 @@ function renderList(usages: AccountUsage[]): string { (u.primaryPercent !== null && u.primaryPercent >= 100) || (u.secondaryPercent !== null && u.secondaryPercent >= 100), ).length; - const lowest5h = lowestPercentLeft(usages.map((u) => u.primaryPercent)); + const lowestPrimary = lowestPercentLeft(usages.map((u) => u.primaryPercent)); const lowestWeekly = lowestPercentLeft(usages.map((u) => u.secondaryPercent)); const primaryWindow = usages.find((u) => u.primaryPercent !== null); const secondaryWindow = usages.find((u) => u.secondaryPercent !== null); @@ -142,7 +142,7 @@ function renderList(usages: AccountUsage[]): string { lines.push(''); lines.push( - `${dim}Accounts: ${usages.length} • Credits available: ${totalCredits} • Exhausted: ${exhausted} • Lowest left: ${formatLowestSummary(primarySummaryLabel, lowest5h)}, ${formatLowestSummary(secondarySummaryLabel, lowestWeekly)}${reset}`, + `${dim}Accounts: ${usages.length} • Credits available: ${totalCredits} • Exhausted: ${exhausted} • Lowest left: ${formatLowestSummary(primarySummaryLabel, lowestPrimary)}, ${formatLowestSummary(secondarySummaryLabel, lowestWeekly)}${reset}`, ); if (totalCredits > 0 && exhausted > 0) { diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 91adbd0..4ae4659 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -6,7 +6,7 @@ import readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; -import { discoverAccounts, findAccount } from '../core/accounts.js'; +import { discoverAccounts, findAccount, resolveCodexHome } from '../core/accounts.js'; import { getCredits, getUsage, @@ -14,6 +14,13 @@ import { generateRequestId, normalizeUsage, } from '../core/api.js'; +import { + clearPendingRedemption, + isAmbiguousConsumeFailure, + isReusablePending, + loadPendingRedemption, + savePendingRedemption, +} from '../core/idempotency.js'; import type { Account, AccountUsage, ResetCredit } from '../core/types.js'; import { formatLimitBar, @@ -203,7 +210,6 @@ async function executeReset(usage: AccountUsage, options: ResetOptions): Promise return { outcome: 'cancelled', windowsReset: 0 }; } - const redeemRequestId = generateRequestId(); const label = usage.account.alias || usage.account.email; const scope = activeWindowDescription(usage, credit); @@ -217,7 +223,44 @@ async function executeReset(usage: AccountUsage, options: ResetOptions): Promise } } - const result = await consumeCredit(usage.account, redeemRequestId, credit?.id); + // Idempotent consume: persist the key before sending, reuse it when retrying + // an unresolved send, and clear it only once the outcome is definitive. + const codexHome = resolveCodexHome(); + const creditId = credit?.id ?? null; + const pending = await loadPendingRedemption(codexHome, usage.account.accountId); + let redeemRequestId: string; + if (pending && isReusablePending(pending, usage.account.accountId, creditId)) { + redeemRequestId = pending.redeemRequestId; + process.stderr.write( + `${y('!')} ${label}: retrying unresolved redemption with its original request id\n`, + ); + } else { + if (pending) { + // The prior send's outcome is still unknown: it may already have spent a + // credit. Say so instead of silently minting a fresh idempotency key. + process.stderr.write( + `${y('!')} ${label}: a previous redemption attempt did not complete and may already have used a credit — starting a new redemption with a fresh request id\n`, + ); + } + redeemRequestId = generateRequestId(); + await savePendingRedemption(codexHome, { + redeemRequestId, + accountId: usage.account.accountId, + creditId, + savedAt: new Date().toISOString(), + }); + } + + let result; + try { + result = await consumeCredit(usage.account, redeemRequestId, credit?.id); + } catch (err) { + if (!isAmbiguousConsumeFailure(err)) { + await clearPendingRedemption(codexHome, usage.account.accountId); + } + throw err; + } + await clearPendingRedemption(codexHome, usage.account.accountId); const windowsReset = result.windows_reset ?? 0; if (result.code === 'noCredit') { diff --git a/src/core/accounts.ts b/src/core/accounts.ts index e18b944..88b615f 100644 --- a/src/core/accounts.ts +++ b/src/core/accounts.ts @@ -10,6 +10,12 @@ * Also checks {codex_home}/auth.json directly for CLI-only users * (no codex-auth installed — single account mode). * + * Auth-mode policy (mirrors upstream AuthMode::has_chatgpt_account()): + * - chatgpt (OAuth tokens) → discovered via id_token claims + * - personalAccessToken → discovered after whoami hydration + * - apikey / agentIdentity / + * bedrockApiKey / token-less → skipped with a stderr warning + * * @module core/accounts */ @@ -17,6 +23,8 @@ import { access, readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { homedir } from 'node:os'; import type { Account, AuthFile } from './types.js'; +import { decodeJwtPayload } from './jwt.js'; +import { fetchPatMetadata } from './auth.js'; /** Resolve the Codex home directory across platforms. */ export function resolveCodexHome(): string { @@ -49,37 +57,60 @@ interface RegistryAccount { email: string; alias: string; account_name: string | null; - plan: string; + plan: string | null; } interface Registry { accounts: RegistryAccount[]; } -/** Decode the payload section of a JWT (base64url → JSON). */ -export function decodeJwtPayload(token: string): Record { - const parts = token.split('.'); - if (parts.length < 2) return {}; - try { - const decoded = Buffer.from(parts[1]!, 'base64url').toString('utf-8'); - return JSON.parse(decoded); - } catch { - return {}; - } -} +export { decodeJwtPayload }; -/** Extract email and account_id from an auth file via JWT id_token claims. */ -export function extractIdentity(auth: AuthFile): { +/** Identity extracted from an auth file via JWT id_token claims. */ +export interface Identity { email: string | null; accountId: string | null; planType: string | null; -} { - const claims = decodeJwtPayload(auth.tokens.id_token); - const email = (claims.email as string) || null; + isFedramp: boolean; +} + +/** Extract identity from an OAuth auth file via JWT id_token claims. */ +export function extractIdentity(auth: AuthFile): Identity { + const idToken = auth.tokens?.id_token; + const claims = typeof idToken === 'string' ? decodeJwtPayload(idToken) : {}; const authClaims = claims['https://api.openai.com/auth'] as Record | undefined; - const accountId = (authClaims?.chatgpt_account_id as string) || auth.tokens.account_id || null; - const planType = (authClaims?.chatgpt_plan_type as string) || null; - return { email, accountId, planType }; + const profileClaims = claims['https://api.openai.com/profile'] as Record | undefined; + + const email = + (typeof claims.email === 'string' && claims.email) || + (typeof profileClaims?.email === 'string' && profileClaims.email) || + null; + + // Upstream precedence (login/src/auth/manager.rs): tokens.account_id (a + // forced workspace override) wins over the id_token claim. The default + // organization id is a last-resort discovery fallback used by the codex-auth + // producer; upstream request auth never substitutes it. + const accountId = + auth.tokens?.account_id || + (typeof authClaims?.chatgpt_account_id === 'string' && authClaims.chatgpt_account_id) || + organizationAccountId(authClaims) || + null; + + const planType = (typeof authClaims?.chatgpt_plan_type === 'string' && authClaims.chatgpt_plan_type) || null; + const isFedramp = authClaims?.chatgpt_account_is_fedramp === true; + + return { email, accountId, planType, isFedramp }; +} + +/** Fallback the producer also accepts: default (or first) organization id. */ +function organizationAccountId(authClaims: Record | undefined): string | null { + const orgs = authClaims?.organizations; + if (!Array.isArray(orgs)) return null; + const records = orgs.filter( + (org): org is Record => typeof org === 'object' && org !== null, + ); + const preferred = records.find((org) => org.is_default === true) ?? records[0]; + return typeof preferred?.id === 'string' ? preferred.id : null; } /** Build a map of account_id → registry metadata for quick lookup. */ @@ -107,6 +138,104 @@ async function fileExists(path: string): Promise { } } +/** Non-fatal discovery note; stderr keeps `--json` stdout machine-readable. */ +function warn(message: string): void { + process.stderr.write(`! ${message}\n`); +} + +function registryMetaFor( + registry: Map, + accountId: string, +): Partial> { + const meta = registry.get(accountId); + return { + email: meta?.email, + alias: meta?.alias, + account_name: meta?.account_name, + plan: meta?.plan, + }; +} + +function buildAccount( + authFile: AuthFile, + filepath: string | null, + identity: Identity, + registry: Map, +): Account { + const meta = registryMetaFor(registry, identity.accountId!); + return { + email: identity.email || meta.email || 'unknown', + planType: identity.planType || meta.plan || 'unknown', + accountId: identity.accountId!, + authFile, + // The producer stores "" for "no alias" — normalize to null. + alias: meta.alias ? meta.alias : null, + accountName: meta.account_name ?? null, + filepath, + isFedramp: identity.isFedramp, + authMode: authFile.auth_mode ?? null, + }; +} + +/** Load one auth file into an Account, or explain why it was skipped. */ +async function loadAuthFile( + authFile: AuthFile, + filepath: string | null, + registry: Map, + label: string, +): Promise { + // ChatGPT OAuth: identity comes from the id_token JWT. + if (authFile.tokens?.id_token) { + const identity = extractIdentity(authFile); + if (!identity.accountId) { + warn(`${label}: no ChatGPT account id in token claims — skipping`); + return null; + } + return buildAccount(authFile, filepath, identity, registry); + } + + // Personal access token: a real ChatGPT account per upstream + // AuthMode::has_chatgpt_account(), hydrated via the whoami endpoint. + if (typeof authFile.personal_access_token === 'string' && authFile.personal_access_token.length > 0) { + try { + const metadata = await fetchPatMetadata(authFile.personal_access_token); + return buildAccount( + authFile, + filepath, + { + email: metadata.email, + accountId: metadata.chatgpt_account_id, + planType: metadata.chatgpt_plan_type, + isFedramp: metadata.chatgpt_account_is_fedramp, + }, + registry, + ); + } catch (err) { + warn( + `${label}: personal access token could not be verified (${ + err instanceof Error ? err.message : String(err) + }) — skipping`, + ); + return null; + } + } + + if (authFile.bedrock_api_key) { + warn(`${label}: Bedrock API-key accounts have no ChatGPT rate limits — skipping`); + return null; + } + if (authFile.agent_identity) { + warn(`${label}: agent-identity accounts have no ChatGPT rate limits — skipping`); + return null; + } + if (authFile.OPENAI_API_KEY) { + warn(`${label}: API-key accounts have no ChatGPT rate limits — skipping`); + return null; + } + warn(`${label}: auth file has no usable credentials — skipping`); + return null; +} + /** Try to load a single auth.json file (for CLI-only users without codex-auth). */ async function tryLoadLiveAuth(codexHome: string): Promise { const liveAuthPath = join(codexHome, 'auth.json'); @@ -114,16 +243,7 @@ async function tryLoadLiveAuth(codexHome: string): Promise { if (!(await fileExists(liveAuthPath))) return null; const content = await readFile(liveAuthPath, 'utf-8'); const authFile = JSON.parse(content) as AuthFile; - const { email, accountId, planType } = extractIdentity(authFile); - if (!accountId) return null; - return { - email: email || 'unknown', - planType: planType || 'unknown', - accountId, - authFile, - alias: null, - accountName: null, - }; + return await loadAuthFile(authFile, liveAuthPath, new Map(), liveAuthPath); } catch { return null; } @@ -150,23 +270,15 @@ export async function discoverAccounts(codexHome = resolveCodexHome()): Promise< try { const content = await readFile(filepath, 'utf-8'); const authFile = JSON.parse(content) as AuthFile; - const { email, accountId, planType } = extractIdentity(authFile); - if (!accountId) continue; + const account = await loadAuthFile(authFile, filepath, registry, filename); + if (!account) continue; // Dedupe by email:account_id (same user can have multiple account entries) - const key = `${email}:${accountId}`; + const key = `${account.email}:${account.accountId}`; if (seen.has(key)) continue; seen.add(key); - const regMeta = registry.get(accountId); - accounts.push({ - email: email || regMeta?.email || 'unknown', - planType: planType || regMeta?.plan || 'unknown', - accountId, - authFile, - alias: regMeta?.alias || null, - accountName: regMeta?.account_name || null, - }); + accounts.push(account); } catch { // Skip unreadable / invalid auth files } diff --git a/src/core/api.ts b/src/core/api.ts index d46e215..e620e6d 100644 --- a/src/core/api.ts +++ b/src/core/api.ts @@ -1,10 +1,9 @@ /** * ChatGPT backend API client — reads usage, lists credits, consumes credits. - * Uses Node.js built-in https module. Zero dependencies. + * Talks through the injectable transport in core/http (zero dependencies). * @module core/api */ -import https from 'node:https'; import { randomUUID } from 'node:crypto'; import type { Account, @@ -16,11 +15,45 @@ import type { UsageWindow, } from './types.js'; import { ApiError } from '../utils/errors.js'; +import { getHttpTransport, TransportError } from './http.js'; +import { accessTokenIsExpired, refreshAccessToken } from './auth.js'; -const BASE_HOST = 'chatgpt.com'; -const BASE_PATH = '/backend-api/wham'; +// Upstream builds these as {base}/wham/... with base = chatgpt.com/backend-api +// (PathStyle::ChatGptApi); endpoint paths below mirror that split exactly. +const DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api'; +const BASE_URL_ENV = 'CODEX_RESET_BASE_URL'; const USER_AGENT = 'codex-reset/0.2.1'; const TIMEOUT_MS = 15_000; +const REFRESH_LOGIN_HINT = + 'Token may be expired and could not be refreshed automatically. Run `codex login` or `codex-auth login`, then retry.'; + +/** Resolve the backend base URL (override for tests via CODEX_RESET_BASE_URL). */ +export function resolveBaseUrl(): URL { + const raw = process.env[BASE_URL_ENV]; + return new URL(raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_BASE_URL); +} + +/** Bearer credential: personal access token when present, else the OAuth access token. */ +export function bearerToken(account: Account): string { + return account.authFile.personal_access_token || account.authFile.tokens?.access_token || ''; +} + +/** Exact request headers for an API call (upstream BearerAuthProvider contract). */ +export function buildRequestHeaders(account: Account, hasBody: boolean): Record { + const headers: Record = { + Authorization: `Bearer ${bearerToken(account)}`, + 'ChatGPT-Account-Id': account.accountId, + 'User-Agent': USER_AGENT, + Accept: 'application/json', + }; + if (account.isFedramp) { + headers['X-OpenAI-Fedramp'] = 'true'; + } + if (hasBody) { + headers['Content-Type'] = 'application/json'; + } + return headers; +} interface RequestOptions { method: 'GET' | 'POST'; @@ -29,53 +62,40 @@ interface RequestOptions { body?: string; } -/** Make a single HTTPS request to the ChatGPT backend. */ -function request(opts: RequestOptions): Promise<{ status: number; data: unknown }> { - return new Promise((resolve, reject) => { - const headers: Record = { - Authorization: `Bearer ${opts.account.authFile.tokens.access_token}`, - 'ChatGPT-Account-Id': opts.account.accountId, - 'User-Agent': USER_AGENT, - Accept: 'application/json', - }; - - if (opts.body) { - headers['Content-Type'] = 'application/json'; - } - - const req = https.request( - { - hostname: BASE_HOST, - path: opts.path, - method: opts.method, - headers, - timeout: TIMEOUT_MS, - }, - (res) => { - let data = ''; - res.on('data', (chunk: Buffer) => (data += chunk.toString())); - res.on('end', () => { - const status = res.statusCode ?? 0; - try { - resolve({ status, data: JSON.parse(data) }); - } catch { - resolve({ status, data }); - } - }); - }, - ); - - req.on('timeout', () => { - req.destroy(new Error('Request timed out')); - }); - - req.on('error', (err: Error) => { - reject(new ApiError(err.message, 0, 'Check your network connection and try again.')); +/** Make a single request to the ChatGPT backend through the active transport. */ +async function request(opts: RequestOptions): Promise<{ + status: number; + headers: Record; + data: unknown; +}> { + const base = resolveBaseUrl(); + const transport = getHttpTransport(); + let res; + try { + res = await transport({ + method: opts.method, + protocol: base.protocol === 'http:' ? 'http:' : 'https:', + hostname: base.hostname, + port: base.port ? Number(base.port) : undefined, + path: `${base.pathname.replace(/\/+$/, '')}${opts.path}`, + headers: buildRequestHeaders(opts.account, opts.body !== undefined), + body: opts.body, + timeoutMs: TIMEOUT_MS, }); + } catch (err) { + if (err instanceof TransportError) { + throw new ApiError(err.message, 0, 'Check your network connection and try again.'); + } + throw err; + } - if (opts.body) req.write(opts.body); - req.end(); - }); + let data: unknown; + try { + data = JSON.parse(res.bodyText); + } catch { + data = res.bodyText; + } + return { status: res.status, headers: res.headers, data }; } function asRecord(value: unknown): Record | null { @@ -170,19 +190,123 @@ export function createConsumeRequestBody(redeemRequestId: string, creditId?: str return JSON.stringify(body); } +/** Format a Retry-After header value (delta seconds or HTTP-date) for display. */ +export function describeRetryAfter(value: string | undefined): string | null { + if (!value) return null; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return `Retry after ${Math.ceil(seconds)} seconds.`; + } + const at = Date.parse(value); + if (!Number.isNaN(at)) { + return `Retry after ${new Date(at).toISOString()}.`; + } + return null; +} + +function unauthorizedError(account: Account, refreshError?: Error): ApiError { + // When a refresh was attempted and failed, its classified message (upstream + // parity: expired / reused / revoked) is the actionable hint. + return new ApiError( + `Unauthorized for ${account.email}`, + 401, + refreshError?.message ?? REFRESH_LOGIN_HINT, + ); +} + +/** + * Run a request with upstream-style token refresh: proactively refresh an + * expired access token, then retry once after a reactive 401. Refreshed + * tokens (including rotation) are persisted back to the account's auth file. + * A failed refresh is returned as `refreshError` rather than thrown. + */ +async function requestWithRefresh(opts: RequestOptions): Promise<{ + status: number; + headers: Record; + data: unknown; + refreshError?: Error; +}> { + const account = opts.account; + const tokens = account.authFile.tokens; + + let refreshError: Error | undefined; + if (tokens?.refresh_token && accessTokenIsExpired(bearerToken(account))) { + refreshError = (await tryRefreshTokens(account)) ?? undefined; + } + + const first = await request(opts); + if (first.status !== 401 || !tokens?.refresh_token) { + return { ...first, refreshError }; + } + + const failure = await tryRefreshTokens(account); + if (failure) return { ...first, refreshError: failure }; + return request(opts); +} + +/** Best-effort refresh; mutates the account's tokens and persists them. Returns the failure, or null on success. */ +async function tryRefreshTokens(account: Account): Promise { + const refreshToken = account.authFile.tokens?.refresh_token; + if (!refreshToken) return new Error('No refresh token available.'); + try { + const refreshed = await refreshAccessToken(refreshToken); + const tokens = account.authFile.tokens; + if (!tokens) return new Error('Stored auth has no token block.'); + if (refreshed.id_token) tokens.id_token = refreshed.id_token; + if (refreshed.access_token) tokens.access_token = refreshed.access_token; + // Rotation: the old refresh token stays valid when no new one is returned. + if (refreshed.refresh_token) tokens.refresh_token = refreshed.refresh_token; + account.authFile.last_refresh = new Date().toISOString(); + await persistAuthFile(account); + return null; + } catch (err) { + return err instanceof Error ? err : new Error(String(err)); + } +} + +/** Write an account's (possibly refreshed) auth file back to disk atomically. */ +async function persistAuthFile(account: Account): Promise { + if (!account.filepath) return; + try { + const { rename, writeFile } = await import('node:fs/promises'); + // temp + rename so a crash mid-write can never leave a truncated auth file. + const temp = `${account.filepath}.codex-reset-tmp`; + await writeFile(temp, JSON.stringify(account.authFile, null, 2) + '\n', 'utf-8'); + await rename(temp, account.filepath); + } catch { + // Persisting refreshed tokens is best-effort; the in-process token still works. + } +} + /** Fetch current usage state for an account. */ export async function getUsage(account: Account): Promise { - const { status, data } = await request({ + const { status, headers, data, refreshError } = await requestWithRefresh({ method: 'GET', - path: `${BASE_PATH}/usage`, + path: '/wham/usage', account, }); if (status === 401) { + throw unauthorizedError(account, refreshError); + } + if (status === 403) { + throw new ApiError( + `Usage API returned HTTP 403 for ${account.email}`, + 403, + account.isFedramp + ? 'The request was sent with FedRAMP routing. The account may not have access to this feature or workspace.' + : 'The account may not have access to this feature or workspace.', + ); + } + if (status === 429) { + const retry = describeRetryAfter(headers['retry-after']); + throw new ApiError(`Usage API rate limited (HTTP 429)`, 429, retry ?? 'Try again later.'); + } + if (status >= 500) { throw new ApiError( - `Unauthorized for ${account.email}`, - 401, - 'Token may be expired. Run `codex-auth login` to refresh, then retry.', + `Usage API returned HTTP ${status}`, + status, + 'The ChatGPT backend is having issues. Try again shortly.', ); } if (status !== 200) { @@ -196,18 +320,14 @@ export async function getUsage(account: Account): Promise { /** Fetch all reset credits (available + redeemed) for an account. */ export async function getCredits(account: Account): Promise { - const { status, data } = await request({ + const { status, data, refreshError } = await requestWithRefresh({ method: 'GET', - path: `${BASE_PATH}/rate-limit-reset-credits`, + path: '/wham/rate-limit-reset-credits', account, }); if (status === 401) { - throw new ApiError( - `Unauthorized for ${account.email}`, - 401, - 'Token may be expired. Run `codex-auth login` to refresh, then retry.', - ); + throw unauthorizedError(account, refreshError); } if (status !== 200) { throw new ApiError(`Credits API returned HTTP ${status}`, status); @@ -249,19 +369,15 @@ export async function consumeCredit( redeemRequestId: string, creditId?: string, ): Promise { - const { status, data } = await request({ + const { status, data, refreshError } = await requestWithRefresh({ method: 'POST', - path: `${BASE_PATH}/rate-limit-reset-credits/consume`, + path: '/wham/rate-limit-reset-credits/consume', account, body: createConsumeRequestBody(redeemRequestId, creditId), }); if (status === 401) { - throw new ApiError( - `Unauthorized for ${account.email}`, - 401, - 'Token may be expired. Run `codex-auth login` to refresh, then retry.', - ); + throw unauthorizedError(account, refreshError); } if (status < 200 || status >= 300) { const errData = asRecord(data); diff --git a/src/core/auth.ts b/src/core/auth.ts new file mode 100644 index 0000000..b810401 --- /dev/null +++ b/src/core/auth.ts @@ -0,0 +1,164 @@ +/** + * Auth flows beyond file parsing: PAT metadata hydration and OAuth token + * refresh. Mirrors upstream codex-rs: + * - login/src/auth/personal_access_token.rs (whoami hydration) + * - login/src/auth/manager.rs (refresh token grant) + * including the same endpoints, client id, and environment overrides. + * @module core/auth + */ + +import { getHttpTransport } from './http.js'; +import { decodeJwtPayload } from './accounts.js'; + +const DEFAULT_WHOAMI_URL = 'https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami'; +const DEFAULT_REFRESH_URL = 'https://auth.openai.com/oauth/token'; +const DEFAULT_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +const AUTH_API_BASE_ENV = 'CODEX_AUTHAPI_BASE_URL'; +const REFRESH_URL_ENV = 'CODEX_REFRESH_TOKEN_URL_OVERRIDE'; +const CLIENT_ID_ENV = 'CODEX_APP_SERVER_LOGIN_CLIENT_ID'; +const REQUEST_TIMEOUT_MS = 15_000; + +/** Metadata returned by the PAT whoami endpoint. */ +export interface PatMetadata { + email: string | null; + chatgpt_user_id: string | null; + chatgpt_account_id: string; + chatgpt_plan_type: string | null; + chatgpt_account_is_fedramp: boolean; +} + +function whoamiUrl(): URL { + const base = process.env[AUTH_API_BASE_ENV]; + const raw = base && base.trim().length > 0 ? `${base.trim().replace(/\/+$/, '')}/v1/user-auth-credential/whoami` : DEFAULT_WHOAMI_URL; + return new URL(raw); +} + +/** Hydrate a personal access token into account metadata (upstream whoami flow). */ +export async function fetchPatMetadata(accessToken: string): Promise { + const transport = getHttpTransport(); + const url = whoamiUrl(); + const res = await transport({ + method: 'GET', + protocol: url.protocol === 'http:' ? 'http:' : 'https:', + hostname: url.hostname, + port: url.port ? Number(url.port) : undefined, + path: `${url.pathname}${url.search}`, + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': 'codex-reset/0.2.1', + }, + timeoutMs: REQUEST_TIMEOUT_MS, + }); + + if (res.status < 200 || res.status >= 300) { + throw new Error(`Personal access token metadata request failed with status ${res.status}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(res.bodyText); + } catch { + throw new Error('Personal access token metadata response was not valid JSON'); + } + const record = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null; + if (!record || typeof record.chatgpt_account_id !== 'string') { + throw new Error('Personal access token metadata response was missing chatgpt_account_id'); + } + return { + email: typeof record.email === 'string' ? record.email : null, + chatgpt_user_id: typeof record.chatgpt_user_id === 'string' ? record.chatgpt_user_id : null, + chatgpt_account_id: record.chatgpt_account_id, + chatgpt_plan_type: typeof record.chatgpt_plan_type === 'string' ? record.chatgpt_plan_type : null, + chatgpt_account_is_fedramp: record.chatgpt_account_is_fedramp === true, + }; +} + +/** Tokens returned by a refresh grant; absent fields are not rotated. */ +export interface RefreshedTokens { + id_token?: string | null; + access_token?: string | null; + refresh_token?: string | null; +} + +function classifyRefreshFailure(status: number, bodyText: string): string { + let code: string | null = null; + try { + const parsed = JSON.parse(bodyText) as Record; + const err = parsed['error']; + if (typeof err === 'string') code = err; + else if (typeof err === 'object' && err !== null) { + const errCode = (err as Record)['code']; + if (typeof errCode === 'string') code = errCode; + } + if (code === null && typeof parsed['code'] === 'string') code = parsed['code']; + } catch { + // non-JSON error body + } + switch (code) { + case 'refresh_token_expired': + return 'Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.'; + case 'refresh_token_reused': + return 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.'; + case 'refresh_token_invalidated': + return 'Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.'; + default: + return status === 401 + ? 'Your access token could not be refreshed. Please log out and sign in again.' + : `Token refresh failed with status ${status}`; + } +} + +/** Exchange a refresh token for fresh OAuth tokens (upstream refresh grant). */ +export async function refreshAccessToken(refreshToken: string): Promise { + const transport = getHttpTransport(); + const url = new URL(process.env[REFRESH_URL_ENV] || DEFAULT_REFRESH_URL); + const clientId = process.env[CLIENT_ID_ENV]?.trim() || DEFAULT_OAUTH_CLIENT_ID; + + const res = await transport({ + method: 'POST', + protocol: url.protocol === 'http:' ? 'http:' : 'https:', + hostname: url.hostname, + port: url.port ? Number(url.port) : undefined, + path: `${url.pathname}${url.search}`, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'codex-reset/0.2.1', + }, + body: JSON.stringify({ + client_id: clientId, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + timeoutMs: REQUEST_TIMEOUT_MS, + }); + + if (res.status < 200 || res.status >= 300) { + throw new Error(classifyRefreshFailure(res.status, res.bodyText)); + } + + let parsed: unknown; + try { + parsed = JSON.parse(res.bodyText); + } catch { + throw new Error('Token refresh response was not valid JSON'); + } + const record = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + return { + id_token: typeof record.id_token === 'string' ? record.id_token : null, + access_token: typeof record.access_token === 'string' ? record.access_token : null, + refresh_token: typeof record.refresh_token === 'string' ? record.refresh_token : null, + }; +} + +/** + * Whether a JWT access token's `exp` claim is in the past. + * Returns false when the token is not a decodable JWT or has no exp — + * callers then rely on reactive 401 handling instead of proactive refresh. + */ +export function accessTokenIsExpired(accessToken: string, nowMs = Date.now()): boolean { + const claims = decodeJwtPayload(accessToken); + const exp = claims['exp']; + if (typeof exp !== 'number' || !Number.isFinite(exp)) return false; + return exp * 1000 <= nowMs; +} diff --git a/src/core/http.ts b/src/core/http.ts new file mode 100644 index 0000000..b50b586 --- /dev/null +++ b/src/core/http.ts @@ -0,0 +1,104 @@ +/** + * Injectable HTTP transport seam. + * + * Production behavior is unchanged: Node's built-in https module against the + * ChatGPT backend. Tests replace the transport via `setHttpTransport()` so the + * exact request boundary (method, path, headers, body) can be asserted without + * network access, and can point the base URL at a local fixture server via the + * `CODEX_RESET_BASE_URL` environment variable. + * + * @module core/http + */ + +import http from 'node:http'; +import https from 'node:https'; + +/** A single outbound request, fully described. */ +export interface TransportRequest { + method: 'GET' | 'POST'; + protocol: 'https:' | 'http:'; + hostname: string; + port?: number; + path: string; + headers: Record; + body?: string; + timeoutMs: number; +} + +/** A complete inbound response. Header names are lower-cased. */ +export interface TransportResponse { + status: number; + headers: Record; + bodyText: string; +} + +/** Transport function; throws on network-level failures (DNS, connect, timeout). */ +export type HttpTransport = (req: TransportRequest) => Promise; + +/** Network-level failure (no HTTP response was received). */ +export class TransportError extends Error { + constructor(message: string) { + super(message); + this.name = 'TransportError'; + } +} + +function flattenHeaders(raw: http.IncomingHttpHeaders): Record { + const headers: Record = {}; + for (const [name, value] of Object.entries(raw)) { + if (value === undefined) continue; + headers[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value); + } + return headers; +} + +/** Default transport: Node built-in http/https, preserving historical behavior. */ +export async function nodeHttpTransport(req: TransportRequest): Promise { + const mod = req.protocol === 'http:' ? http : https; + return new Promise((resolve, reject) => { + const outgoing = mod.request( + { + hostname: req.hostname, + port: req.port, + path: req.path, + method: req.method, + headers: req.headers, + timeout: req.timeoutMs, + }, + (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => (data += chunk.toString())); + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + headers: flattenHeaders(res.headers), + bodyText: data, + }); + }); + }, + ); + + outgoing.on('timeout', () => { + outgoing.destroy(new TransportError('Request timed out')); + }); + + outgoing.on('error', (err: Error) => { + reject(err instanceof TransportError ? err : new TransportError(err.message)); + }); + + if (req.body) outgoing.write(req.body); + outgoing.end(); + }); +} + +let activeTransport: HttpTransport | null = null; + +/** Replace the transport (tests) or restore the default (pass null). */ +export function setHttpTransport(transport: HttpTransport | null): void { + activeTransport = transport; +} + +/** The active transport — injected if set, otherwise the Node built-in client. */ +export function getHttpTransport(): HttpTransport { + return activeTransport ?? nodeHttpTransport; +} diff --git a/src/core/idempotency.ts b/src/core/idempotency.ts new file mode 100644 index 0000000..463f067 --- /dev/null +++ b/src/core/idempotency.ts @@ -0,0 +1,101 @@ +/** + * Idempotent consume support. + * + * A credit consume is a destructive POST: if the request times out or the + * response is lost, retrying with a *new* idempotency key can spend a second + * credit. Upstream's TUI avoids this by reusing one idempotency key for the + * whole redemption flow (tui/src/chatwidget/usage.rs). This module persists + * the key before the send and keeps it until the outcome is resolved, so a + * retry of an ambiguous send — including across CLI invocations — reuses it. + * + * @module core/idempotency + */ + +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ApiError } from '../utils/errors.js'; + +/** A consume that was sent but whose outcome is (or may be) unresolved. */ +export interface PendingRedemption { + redeemRequestId: string; + accountId: string; + creditId: string | null; + savedAt: string; +} + +/** How long an unresolved send may be retried with the same key. */ +export const REUSE_WINDOW_MS = 24 * 60 * 60 * 1000; + +function pendingFile(codexHome: string, accountId: string): string { + // base64url is injective and filesystem-safe: distinct account ids can + // never collide onto the same pending file. + const safe = Buffer.from(accountId, 'utf-8').toString('base64url'); + return join(codexHome, `pending-redeem.${safe}.json`); +} + +/** Load the persisted pending redemption for an account, if any. */ +export async function loadPendingRedemption( + codexHome: string, + accountId: string, +): Promise { + try { + const content = await readFile(pendingFile(codexHome, accountId), 'utf-8'); + const parsed = JSON.parse(content) as Partial; + if ( + typeof parsed.redeemRequestId !== 'string' || + typeof parsed.accountId !== 'string' || + typeof parsed.savedAt !== 'string' + ) { + return null; + } + return { ...parsed, creditId: typeof parsed.creditId === 'string' ? parsed.creditId : null } as PendingRedemption; + } catch { + return null; + } +} + +/** Persist a pending redemption before the consume request is sent. */ +export async function savePendingRedemption( + codexHome: string, + pending: PendingRedemption, +): Promise { + const target = pendingFile(codexHome, pending.accountId); + await mkdir(join(codexHome), { recursive: true }); + await writeFile(target, JSON.stringify(pending, null, 2) + '\n', 'utf-8'); +} + +/** Remove the pending record once the outcome is resolved. */ +export async function clearPendingRedemption(codexHome: string, accountId: string): Promise { + await rm(pendingFile(codexHome, accountId), { force: true }); +} + +/** + * Whether a persisted pending redemption may be retried with the same key: + * it must target the same account + credit and still be inside the reuse window. + */ +export function isReusablePending( + pending: PendingRedemption, + accountId: string, + creditId: string | null, + nowMs = Date.now(), +): boolean { + if (pending.accountId !== accountId) return false; + if (pending.creditId !== creditId) return false; + const savedAt = Date.parse(pending.savedAt); + if (Number.isNaN(savedAt)) return false; + return nowMs - savedAt <= REUSE_WINDOW_MS; +} + +/** + * Whether a consume failure leaves the outcome ambiguous (the server may or + * may not have spent the credit). Ambiguous failures keep the idempotency key + * so a retry cannot double-spend: + * - status 0: no HTTP response (timeout, connection reset, DNS) + * - status 200 with an error: 2xx body that failed to parse — consumed but unreadable + * - status >= 500: server-side failure after an unknown amount of processing + * Definitive 4xx answers mean the server did not accept the redemption. + */ +export function isAmbiguousConsumeFailure(err: unknown): boolean { + if (!(err instanceof ApiError)) return false; + return err.statusCode === 0 || err.statusCode === 200 || err.statusCode >= 500; +} diff --git a/src/core/jwt.ts b/src/core/jwt.ts new file mode 100644 index 0000000..61ac6d7 --- /dev/null +++ b/src/core/jwt.ts @@ -0,0 +1,16 @@ +/** + * Minimal JWT helpers (no external dependencies). + * @module core/jwt + */ + +/** Decode the payload section of a JWT (base64url → JSON). Returns {} on failure. */ +export function decodeJwtPayload(token: string): Record { + const parts = token.split('.'); + if (parts.length < 2) return {}; + try { + const decoded = Buffer.from(parts[1]!, 'base64url').toString('utf-8'); + return JSON.parse(decoded); + } catch { + return {}; + } +} diff --git a/src/core/types.ts b/src/core/types.ts index 10eb0fd..9354b0e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3,20 +3,28 @@ * @module core/types */ -/** Raw auth.json token block. */ +/** Raw auth.json token block (upstream login TokenData). */ export interface AuthTokens { access_token: string; refresh_token: string; id_token: string; - account_id: string; + account_id?: string | null; } -/** Raw ~/.codex/accounts/*.auth.json structure. */ +/** + * Raw ~/.codex/auth.json / ~/.codex/accounts/*.auth.json structure. + * Mirrors upstream `AuthDotJson`: every field except the token block is + * optional, and modes other than ChatGPT OAuth carry their own credential + * field instead of `tokens`. + */ export interface AuthFile { - auth_mode: string; - OPENAI_API_KEY: string | null; - tokens: AuthTokens; - last_refresh: string; + auth_mode?: string | null; + OPENAI_API_KEY?: string | null; + tokens?: AuthTokens | null; + last_refresh?: string | null; + agent_identity?: unknown; + personal_access_token?: string | null; + bedrock_api_key?: unknown; } /** A single rate-limit window. Codex may omit either window for some plans. */ @@ -82,7 +90,12 @@ export interface ConsumeResponse { }; } -/** A discovered account ready for API calls. */ +/** + * A discovered account ready for API calls. Bearer credential resolution: + * ChatGPT OAuth → `authFile.tokens.access_token`; PAT → hydrated at discovery. + * Fields marked optional were added after 0.2.0 and stay optional so external + * callers constructing an Account keep type-checking; discovery always sets them. + */ export interface Account { email: string; planType: string; @@ -90,6 +103,12 @@ export interface Account { authFile: AuthFile; alias: string | null; accountName: string | null; + /** Auth file the account was loaded from; null only for synthetic accounts. */ + filepath?: string | null; + /** True when the id_token (or PAT metadata) marks the account as FedRAMP. */ + isFedramp?: boolean; + /** Raw auth_mode value ("chatgpt", "personalAccessToken", ...). */ + authMode?: string | null; } /** Normalized usage snapshot for display. `null` means the backend did not report that window. */ diff --git a/src/index.ts b/src/index.ts index 9a14be7..62e992a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,10 +6,39 @@ export { discoverAccounts, findAccount, + resolveCodexHome, decodeJwtPayload, extractIdentity, } from './core/accounts.js'; -export { getUsage, getCredits, consumeCredit, generateRequestId } from './core/api.js'; +export { + getUsage, + getCredits, + consumeCredit, + generateRequestId, + bearerToken, + buildRequestHeaders, + createConsumeRequestBody, +} from './core/api.js'; +export { + setHttpTransport, + getHttpTransport, + nodeHttpTransport, +} from './core/http.js'; +export type { TransportRequest, TransportResponse, HttpTransport } from './core/http.js'; +export { + fetchPatMetadata, + refreshAccessToken, + accessTokenIsExpired, +} from './core/auth.js'; +export type { PatMetadata, RefreshedTokens } from './core/auth.js'; +export { + loadPendingRedemption, + savePendingRedemption, + clearPendingRedemption, + isReusablePending, + isAmbiguousConsumeFailure, +} from './core/idempotency.js'; +export type { PendingRedemption } from './core/idempotency.js'; export type { Account, AccountUsage, diff --git a/src/utils/format.ts b/src/utils/format.ts index 00fbc44..92117e4 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -156,28 +156,39 @@ export function pad(str: string, width: number): string { return str + ' '.repeat(width - str.length); } -/** Convert backend plan strings to readable labels without hard-coding account classes. */ +/** + * Official upstream display names per raw plan value, from + * codex-rs/protocol/src/auth.rs `KnownPlan::display_name()`. + */ +const PLAN_DISPLAY_NAMES: Record = { + free: 'Free', + go: 'Go', + plus: 'Plus', + pro: 'Pro', + prolite: 'Pro Lite', + team: 'Team', + self_serve_business_prolite: 'Self Serve Business ProLite', + self_serve_business_usage_based: 'Self Serve Business Usage Based', + business: 'Business', + ent26: 'Enterprise', + enterprise: 'Enterprise', + hc: 'Enterprise', + enterprise_cbp_automation: 'Enterprise (Automation)', + enterprise_cbp_usage_based: 'Enterprise CBP Usage Based', + edu: 'Edu', + education: 'Edu', +}; + +/** Convert backend plan strings to official Codex display names; unknown values fall back to title-casing. */ export function planDisplayName(plan: string): string { const normalized = plan.trim().toLowerCase(); - switch (normalized) { - case 'free': - return 'Free'; - case 'go': - return 'Go'; - case 'plus': - return 'Plus'; - case 'pro': - return 'Pro'; - case 'prolite': - case 'pro_lite': - return 'Pro Lite'; - default: - return normalized - .split(/[_\s-]+/) - .filter(Boolean) - .map((part) => part[0]!.toUpperCase() + part.slice(1)) - .join(' '); - } + const known = PLAN_DISPLAY_NAMES[normalized]; + if (known) return known; + return normalized + .split(/[_\s-]+/) + .filter(Boolean) + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(' '); } /** Plan type to colored badge. */ diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 554c88d..55fbff9 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1,8 +1,8 @@ -import { describe, it } from 'node:test'; +import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; + import { decodeJwtPayload, discoverAccounts, @@ -10,7 +10,20 @@ import { findAccount, resolveCodexHome, } from '../src/core/accounts.ts'; +import { setHttpTransport } from '../src/core/http.ts'; +import type { TransportResponse } from '../src/core/http.ts'; import type { Account, AuthFile } from '../src/core/types.ts'; +import { + captureOutput, + fakeTransport, + jsonResponse, + oauthAuthFile, + patAuthFile, + withTempCodexHome, +} from './helpers.ts'; + +// Never let one test's injected transport leak into the next on failure. +afterEach(() => setHttpTransport(null)); // A minimal JWT with email and auth claims for testing function makeJwt(claims: Record): string { @@ -42,15 +55,6 @@ const testAuthFile: AuthFile = { last_refresh: '2026-06-22T00:00:00Z', }; -async function withTempCodexHome(fn: (codexHome: string) => Promise): Promise { - const codexHome = await mkdtemp(join(tmpdir(), 'codex-reset-test-')); - try { - return await fn(codexHome); - } finally { - await rm(codexHome, { recursive: true, force: true }); - } -} - describe('decodeJwtPayload', () => { it('decodes a valid JWT', () => { const payload = decodeJwtPayload(testJwt); @@ -116,7 +120,7 @@ describe('resolveCodexHome', () => { describe('discoverAccounts', () => { it('loads codex-auth managed accounts and registry metadata', async () => { - await withTempCodexHome(async (codexHome) => { + await withTempCodexHome({}, async (codexHome) => { const accountsDir = join(codexHome, 'accounts'); await mkdir(accountsDir, { recursive: true }); await writeFile(join(accountsDir, 'acct.auth.json'), JSON.stringify(testAuthFile)); @@ -149,7 +153,7 @@ describe('discoverAccounts', () => { }); it('falls back to live auth.json when accounts directory is absent', async () => { - await withTempCodexHome(async (codexHome) => { + await withTempCodexHome({}, async (codexHome) => { await writeFile(join(codexHome, 'auth.json'), JSON.stringify(testAuthFile)); const accounts = await discoverAccounts(codexHome); @@ -217,3 +221,233 @@ describe('findAccount', () => { assert.strictEqual(result, undefined); }); }); + +describe('auth-mode fixture matrix', () => { + // A transport that fails the test if any non-PAT mode touches the network. + function offlineTransport() { + const { transport } = fakeTransport(() => { + throw new Error('network must not be used in offline discovery modes'); + }); + setHttpTransport(transport); + } + + it('discovers a personal access token account via whoami hydration', async () => { + const { transport, requests } = fakeTransport( + (req): TransportResponse => { + assert.equal(req.hostname, 'auth.openai.com'); + assert.equal(req.path, '/api/accounts/v1/user-auth-credential/whoami'); + return jsonResponse(200, { + email: 'pat-user@example.com', + chatgpt_user_id: 'user-pat', + chatgpt_account_id: 'acct-pat', + chatgpt_plan_type: 'pro', + chatgpt_account_is_fedramp: true, + }); + }, + ); + setHttpTransport(transport); + + await withTempCodexHome( + { 'accounts/pat.auth.json': JSON.stringify(patAuthFile('pat-token-1')) }, + async () => { + const accounts = await discoverAccounts(); + assert.equal(accounts.length, 1); + const acct = accounts[0]!; + assert.equal(acct.email, 'pat-user@example.com'); + assert.equal(acct.accountId, 'acct-pat'); + assert.equal(acct.planType, 'pro'); + assert.equal(acct.isFedramp, true); + assert.equal(acct.authMode, 'personalAccessToken'); + assert.equal(requests[0]!.headers['Authorization'], 'Bearer pat-token-1'); + }, + ); + setHttpTransport(null); + }); + + it('skips a PAT account whose whoami lookup fails, with a warning', async () => { + const { transport } = fakeTransport(() => jsonResponse(403, {})); + setHttpTransport(transport); + const { stderr } = await captureOutput(async () => { + const accounts = await withTempCodexHome( + { 'accounts/pat.auth.json': JSON.stringify(patAuthFile()) }, + () => discoverAccounts(), + ); + assert.equal(accounts.length, 0); + }); + assert.match(stderr, /personal access token could not be verified/); + setHttpTransport(null); + }); + + it('skips API-key accounts with a warning and no network calls', async () => { + offlineTransport(); + const { stderr } = await captureOutput(async () => { + const accounts = await withTempCodexHome( + { + 'accounts/sk.auth.json': JSON.stringify({ + auth_mode: 'apikey', + OPENAI_API_KEY: 'sk-test', + }), + }, + () => discoverAccounts(), + ); + assert.equal(accounts.length, 0); + }); + assert.match(stderr, /API-key accounts/); + setHttpTransport(null); + }); + + it('skips agent-identity accounts with a warning', async () => { + offlineTransport(); + const { stderr } = await captureOutput(async () => { + const accounts = await withTempCodexHome( + { + 'accounts/agent.auth.json': JSON.stringify({ + auth_mode: 'agentIdentity', + agent_identity: 'agent-jwt', + }), + }, + () => discoverAccounts(), + ); + assert.equal(accounts.length, 0); + }); + assert.match(stderr, /agent-identity/); + setHttpTransport(null); + }); + + it('skips Bedrock accounts with a warning', async () => { + offlineTransport(); + const { stderr } = await captureOutput(async () => { + const accounts = await withTempCodexHome( + { + 'accounts/bedrock.auth.json': JSON.stringify({ + auth_mode: 'bedrockApiKey', + bedrock_api_key: { region: 'us-east-1' }, + }), + }, + () => discoverAccounts(), + ); + assert.equal(accounts.length, 0); + }); + assert.match(stderr, /Bedrock/); + setHttpTransport(null); + }); + + it('skips a token-less credential-free file without crashing', async () => { + offlineTransport(); + const { stderr } = await captureOutput(async () => { + const accounts = await withTempCodexHome( + { 'accounts/empty.auth.json': JSON.stringify({ auth_mode: 'chatgpt' }) }, + () => discoverAccounts(), + ); + assert.equal(accounts.length, 0); + }); + assert.match(stderr, /no usable credentials/); + setHttpTransport(null); + }); + + it('carries the FedRAMP claim onto the account', async () => { + offlineTransport(); + const accounts = await withTempCodexHome( + { 'accounts/fed.auth.json': JSON.stringify(oauthAuthFile({ fedramp: true })) }, + () => discoverAccounts(), + ); + assert.equal(accounts[0]?.isFedramp, true); + setHttpTransport(null); + }); + + it('still discovers an account whose access token is expired (refresh is reactive)', async () => { + offlineTransport(); + const accounts = await withTempCodexHome( + { 'accounts/exp.auth.json': JSON.stringify(oauthAuthFile({ accessExp: 1_000_000_000 })) }, + () => discoverAccounts(), + ); + assert.equal(accounts.length, 1); + setHttpTransport(null); + }); + + it('falls back to the profile.email claim when top-level email is absent', async () => { + const authFile = oauthAuthFile(); + const claims = decodeJwtPayload(authFile.tokens!.id_token); + delete claims.email!; + claims['https://api.openai.com/profile'] = { email: 'profile@example.com' }; + const idToken = `${authFile.tokens!.id_token.split('.')[0]}.${Buffer.from( + JSON.stringify(claims), + ).toString('base64url')}.sig`; + offlineTransport(); + const accounts = await withTempCodexHome( + { + 'accounts/prof.auth.json': JSON.stringify({ + ...authFile, + tokens: { ...authFile.tokens!, id_token: idToken }, + }), + }, + () => discoverAccounts(), + ); + assert.equal(accounts[0]?.email, 'profile@example.com'); + setHttpTransport(null); + }); + + it('falls back to the default organization id when the account-id claim is absent', async () => { + const authFile = oauthAuthFile(); + const claims = decodeJwtPayload(authFile.tokens!.id_token); + const auth = claims['https://api.openai.com/auth'] as Record; + delete auth.chatgpt_account_id; + auth.organizations = [{ id: 'org-2' }, { id: 'org-1', is_default: true }]; + const idToken = `${authFile.tokens!.id_token.split('.')[0]}.${Buffer.from( + JSON.stringify(claims), + ).toString('base64url')}.sig`; + offlineTransport(); + const accounts = await withTempCodexHome( + { + 'accounts/org.auth.json': JSON.stringify({ + ...authFile, + tokens: { ...authFile.tokens!, id_token: idToken, account_id: null }, + }), + }, + () => discoverAccounts(), + ); + assert.equal(accounts[0]?.accountId, 'org-1'); + setHttpTransport(null); + }); + + it('prefers tokens.account_id over the claim, matching upstream request auth', async () => { + const authFile = oauthAuthFile(); // claim says acct-123 + offlineTransport(); + const accounts = await withTempCodexHome( + { + 'accounts/forced.auth.json': JSON.stringify({ + ...authFile, + tokens: { ...authFile.tokens!, account_id: 'forced-workspace-9' }, + }), + }, + () => discoverAccounts(), + ); + assert.equal(accounts[0]?.accountId, 'forced-workspace-9'); + setHttpTransport(null); + }); + + it('normalizes an empty-string registry alias to null', async () => { + offlineTransport(); + const accounts = await withTempCodexHome( + { + 'accounts/acct.auth.json': JSON.stringify(oauthAuthFile()), + 'accounts/registry.json': JSON.stringify({ + accounts: [ + { + account_key: 'user-456::acct-123', + chatgpt_account_id: 'acct-123', + chatgpt_user_id: 'user-456', + email: 'test@example.com', + alias: '', + account_name: null, + plan: null, + }, + ], + }), + }, + () => discoverAccounts(), + ); + assert.equal(accounts[0]?.alias, null); + setHttpTransport(null); + }); +}); diff --git a/test/api-boundary.test.ts b/test/api-boundary.test.ts new file mode 100644 index 0000000..c13fdd6 --- /dev/null +++ b/test/api-boundary.test.ts @@ -0,0 +1,366 @@ +/** + * Request-boundary and status/error-matrix tests. All HTTP goes through an + * injected fake transport, so these assert the exact wire contract (method, + * path, headers, body) without network access. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setHttpTransport, TransportError } from '../src/core/http.ts'; +import type { TransportResponse } from '../src/core/http.ts'; +import { consumeCredit, getCredits, getUsage } from '../src/core/api.ts'; +import { ApiError } from '../src/utils/errors.ts'; +import { + fakeTransport, + jsonResponse, + oauthAccount, + oauthAuthFile, + patAuthFile, + withEnv, +} from './helpers.ts'; + +const USAGE_OK = jsonResponse(200, { + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { used_percent: 42, limit_window_seconds: 604800, reset_at: 1_750_000_000 }, + secondary_window: null, + }, + rate_limit_reset_credits: { available_count: 2 }, +}); + +let cleanupFns: Array<() => void | Promise> = []; + +beforeEach(() => { + cleanupFns = []; +}); + +afterEach(async () => { + setHttpTransport(null); + for (const fn of cleanupFns) await fn(); + cleanupFns = []; +}); + +describe('request boundary', () => { + it('sends GET /backend-api/wham/usage with upstream headers', async () => { + const { transport, requests } = fakeTransport(() => USAGE_OK); + setHttpTransport(transport); + await getUsage(oauthAccount()); + const req = requests[0]!; + assert.equal(req.method, 'GET'); + assert.equal(req.path, '/backend-api/wham/usage'); + assert.equal(req.hostname, 'chatgpt.com'); + assert.equal(req.headers['Authorization'], 'Bearer ' + oauthAuthFile().tokens!.access_token); + assert.equal(req.headers['ChatGPT-Account-Id'], 'acct-123'); + assert.equal(req.headers['Accept'], 'application/json'); + assert.equal(req.headers['User-Agent'], 'codex-reset/0.2.1'); + assert.equal(req.headers['Content-Type'], undefined); + }); + + it('sends GET /backend-api/wham/rate-limit-reset-credits', async () => { + const { transport, requests } = fakeTransport(() => + jsonResponse(200, { credits: [], available_count: 0 }), + ); + setHttpTransport(transport); + await getCredits(oauthAccount()); + assert.equal(requests[0]!.path, '/backend-api/wham/rate-limit-reset-credits'); + }); + + it('sends POST /backend-api/wham/rate-limit-reset-credits/consume with the exact body', async () => { + const { transport, requests } = fakeTransport(() => + jsonResponse(200, { code: 'reset', windows_reset: 1 }), + ); + setHttpTransport(transport); + await consumeCredit(oauthAccount(), 'redeem-1', 'credit-9'); + const req = requests[0]!; + assert.equal(req.method, 'POST'); + assert.equal(req.path, '/backend-api/wham/rate-limit-reset-credits/consume'); + assert.equal(req.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(req.body!), { + redeem_request_id: 'redeem-1', + credit_id: 'credit-9', + }); + }); + + it('honors the CODEX_RESET_BASE_URL override', async () => { + const { transport, requests } = fakeTransport(() => USAGE_OK); + setHttpTransport(transport); + await withEnv({ CODEX_RESET_BASE_URL: 'http://localhost:9/prefix' }, () => getUsage(oauthAccount())); + assert.equal(requests[0]!.path, '/prefix/wham/usage'); + assert.equal(requests[0]!.protocol, 'http:'); + assert.equal(requests[0]!.port, 9); + }); + + it('emits X-OpenAI-Fedramp: true only for FedRAMP accounts', async () => { + const { transport, requests } = fakeTransport(() => USAGE_OK); + setHttpTransport(transport); + await getUsage(oauthAccount({ fedramp: true })); + await getUsage(oauthAccount({ fedramp: false })); + assert.equal(requests[0]!.headers['X-OpenAI-Fedramp'], 'true'); + assert.equal(requests[1]!.headers['X-OpenAI-Fedramp'], undefined); + }); + + it('uses the personal access token as bearer for PAT accounts', async () => { + const pat = patAuthFile('pat-token-1'); + const account = { + ...oauthAccount(), + authFile: pat, + authMode: 'personalAccessToken', + }; + const { transport, requests } = fakeTransport(() => USAGE_OK); + setHttpTransport(transport); + await getUsage(account); + assert.equal(requests[0]!.headers['Authorization'], 'Bearer pat-token-1'); + }); +}); + +describe('token refresh (upstream refresh grant)', () => { + async function tempAuthFile(): Promise<{ path: string; content: string }> { + const dir = await mkdtemp(join(tmpdir(), 'codex-reset-refresh-')); + cleanupFns.push(() => rm(dir, { recursive: true, force: true })); + const content = JSON.stringify(oauthAuthFile({ refreshToken: 'refresh-old' }), null, 2); + const path = join(dir, 'acct.auth.json'); + await writeFile(path, content, 'utf-8'); + return { path, content }; + } + + it('refreshes and retries once on 401, persisting rotated tokens', async () => { + const { path } = await tempAuthFile(); + const account = oauthAccount({ refreshToken: 'refresh-old' }); + account.filepath = path; + + let usageCalls = 0; + const { transport, requests } = fakeTransport((req) => { + if (req.hostname === 'auth.openai.com' && req.path === '/oauth/token') { + return jsonResponse(200, { + id_token: 'new-id', + access_token: 'new-access', + refresh_token: 'refresh-rotated', + }); + } + usageCalls += 1; + return usageCalls === 1 ? jsonResponse(401, { error: 'token expired' }) : USAGE_OK; + }); + setHttpTransport(transport); + + const usage = await getUsage(account); + assert.equal(usage.rate_limit?.primary_window?.used_percent, 42); + + const refresh = requests.find((r) => r.path === '/oauth/token')!; + assert.equal(refresh.method, 'POST'); + assert.equal(refresh.hostname, 'auth.openai.com'); + assert.deepEqual(JSON.parse(refresh.body!), { + client_id: 'app_EMoamEEZ73f0CkXaXp7hrann', + grant_type: 'refresh_token', + refresh_token: 'refresh-old', + }); + // Retry uses the rotated access token. + const retry = requests.filter((r) => r.path.endsWith('/usage'))[1]!; + assert.equal(retry.headers['Authorization'], 'Bearer new-access'); + + const persisted = JSON.parse(await readFile(path, 'utf-8')); + assert.equal(persisted.tokens.access_token, 'new-access'); + assert.equal(persisted.tokens.refresh_token, 'refresh-rotated'); + assert.equal(persisted.tokens.id_token, 'new-id'); + assert.ok(persisted.last_refresh > '2026-08-01'); + }); + + it('keeps the old refresh token when the server does not rotate it', async () => { + const { path } = await tempAuthFile(); + const account = oauthAccount({ refreshToken: 'refresh-old' }); + account.filepath = path; + let usageCalls = 0; + const { transport } = fakeTransport((req) => { + if (req.path === '/oauth/token') { + return jsonResponse(200, { access_token: 'new-access' }); + } + usageCalls += 1; + return usageCalls === 1 ? jsonResponse(401, {}) : USAGE_OK; + }); + setHttpTransport(transport); + await getUsage(account); + const persisted = JSON.parse(await readFile(path, 'utf-8')); + assert.equal(persisted.tokens.refresh_token, 'refresh-old'); + }); + + it('proactively refreshes an expired access token before the first request', async () => { + const { path } = await tempAuthFile(); + const account = oauthAccount({ accessExp: 1_000_000_000, refreshToken: 'refresh-old' }); + account.filepath = path; + const { transport, requests } = fakeTransport((req) => { + if (req.path === '/oauth/token') return jsonResponse(200, { access_token: 'fresh' }); + return USAGE_OK; + }); + setHttpTransport(transport); + await getUsage(account); + assert.equal(requests[0]!.path, '/oauth/token'); + assert.equal(requests[1]!.headers['Authorization'], 'Bearer fresh'); + }); + + it('surfaces the classified refresh failure as the 401 hint', async () => { + const account = oauthAccount({ refreshToken: 'refresh-old' }); + const { transport } = fakeTransport((req) => { + if (req.path === '/oauth/token') { + return jsonResponse(401, { error: { code: 'refresh_token_expired' } }); + } + return jsonResponse(401, {}); + }); + setHttpTransport(transport); + await assert.rejects( + getUsage(account), + (err: unknown) => + err instanceof ApiError && + err.statusCode === 401 && + // The upstream-parity classified message must reach the user, not the generic hint. + (err.hint ?? '').includes('refresh token has expired'), + ); + }); + + it('refreshes and retries consumeCredit after a 401 too', async () => { + const account = oauthAccount({ refreshToken: 'refresh-old' }); + let consumeCalls = 0; + const { transport, requests } = fakeTransport((req) => { + if (req.path === '/oauth/token') { + return jsonResponse(200, { access_token: 'new-access' }); + } + consumeCalls += 1; + if (consumeCalls === 1) return jsonResponse(401, {}); + return jsonResponse(200, { code: 'reset', windows_reset: 1 }); + }); + setHttpTransport(transport); + const result = await consumeCredit(account, 'redeem-9', 'credit-9'); + assert.equal(result.code, 'reset'); + const consumeAttempts = requests.filter((r) => r.path.endsWith('/consume')); + assert.equal(consumeAttempts.length, 2); + assert.equal(consumeAttempts[1]!.headers['Authorization'], 'Bearer new-access'); + }); +}); + +describe('status/error matrix', () => { + async function usageError(status: number, bodyText: string, headers: Record = {}): Promise { + const { transport } = fakeTransport( + (): TransportResponse => ({ status, headers, bodyText }), + ); + setHttpTransport(transport); + return getUsage(oauthAccount({ refreshToken: undefined })).catch((err: unknown) => err); + } + + it('401 produces an Unauthorized error with a login hint', async () => { + const err = (await usageError(401, '{}')) as ApiError; + assert.ok(err instanceof ApiError); + assert.equal(err.statusCode, 401); + assert.match(err.message, /Unauthorized for test@example.com/); + // Either the upstream classified refresh message or the generic login hint. + assert.match(err.hint ?? '', /sign in again|codex login|codex-auth login/); + }); + + it('403 names the account and scopes the hint', async () => { + const err = (await usageError(403, '{}')) as ApiError; + assert.ok(err instanceof ApiError); + assert.equal(err.statusCode, 403); + assert.match(err.message, /HTTP 403 for test@example.com/); + assert.match(err.hint ?? '', /workspace|feature/); + }); + + it('403 mentions FedRAMP routing for FedRAMP accounts', async () => { + const { transport } = fakeTransport(() => jsonResponse(403, {})); + setHttpTransport(transport); + const err = (await getUsage(oauthAccount({ fedramp: true })).catch((e: unknown) => e)) as ApiError; + assert.match(err.hint ?? '', /FedRAMP/); + }); + + it('429 preserves a numeric Retry-After', async () => { + const err = (await usageError(429, '{}', { 'retry-after': '30' })) as ApiError; + assert.ok(err instanceof ApiError); + assert.equal(err.statusCode, 429); + assert.equal(err.hint, 'Retry after 30 seconds.'); + }); + + it('429 preserves an HTTP-date Retry-After', async () => { + const when = new Date(Date.now() + 60_000).toUTCString(); + const err = (await usageError(429, '{}', { 'retry-after': when })) as ApiError; + assert.match(err.hint ?? '', /^Retry after \d{4}-\d{2}-\d{2}T/); + }); + + it('5xx points at the backend', async () => { + const err = (await usageError(503, 'maintenance')) as ApiError; + assert.ok(err instanceof ApiError); + assert.equal(err.statusCode, 503); + assert.match(err.hint ?? '', /backend/i); + }); + + it('treats a 200 HTML body as an invalid response', async () => { + const err = (await usageError(200, 'login page')) as ApiError; + assert.ok(err instanceof ApiError); + assert.match(err.message, /invalid response/); + }); + + it('treats an empty 200 body as an invalid response', async () => { + const err = (await usageError(200, '')) as ApiError; + assert.ok(err instanceof ApiError); + assert.match(err.message, /invalid response/); + }); + + it('treats truncated JSON as an invalid response', async () => { + const err = (await usageError(200, '{"rate_limit": {')) as ApiError; + assert.ok(err instanceof ApiError); + assert.match(err.message, /invalid response/); + }); + + it('survives an oversized response body', async () => { + const err = (await usageError(200, 'x'.repeat(2 * 1024 * 1024))) as ApiError; + assert.ok(err instanceof ApiError); + assert.match(err.message, /invalid response/); + }); + + it('wraps network failures with a connectivity hint', async () => { + const { transport } = fakeTransport(() => { + throw new TransportError('Request timed out'); + }); + setHttpTransport(transport); + const err = (await getUsage(oauthAccount()).catch((e: unknown) => e)) as ApiError; + assert.ok(err instanceof ApiError); + assert.equal(err.statusCode, 0); + assert.equal(err.message, 'Request timed out'); + assert.match(err.hint ?? '', /network/); + }); + + it('rejects an unknown consume result code', async () => { + const { transport } = fakeTransport(() => jsonResponse(200, { code: 'what_is_this' })); + setHttpTransport(transport); + const err = (await consumeCredit(oauthAccount(), 'r1').catch((e: unknown) => e)) as ApiError; + assert.ok(err instanceof ApiError); + assert.equal(err.statusCode, 200); + assert.match(err.message, /unknown result code/); + }); + + it('accepts authoritative snake_case consume codes and legacy camelCase aliases', async () => { + for (const [wire, normalized] of [ + ['nothing_to_reset', 'nothingToReset'], + ['no_credit', 'noCredit'], + ['already_redeemed', 'alreadyRedeemed'], + ['nothingToReset', 'nothingToReset'], + ] as const) { + const { transport } = fakeTransport(() => + jsonResponse(200, { code: wire, windows_reset: 0 }), + ); + setHttpTransport(transport); + const result = await consumeCredit(oauthAccount(), 'r1'); + assert.equal(result.code, normalized, wire); + } + }); + + it('surfaces nested error messages from a failed consume', async () => { + const { transport } = fakeTransport(() => + jsonResponse(409, { error: { message: 'credit already spent' } }), + ); + setHttpTransport(transport); + const err = (await consumeCredit(oauthAccount(), 'r1').catch((e: unknown) => e)) as ApiError; + assert.ok(err instanceof ApiError); + assert.equal(err.statusCode, 409); + assert.match(err.message, /credit already spent/); + }); +}); diff --git a/test/api.test.ts b/test/api.test.ts index 74203de..1251e7e 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -26,6 +26,9 @@ const account: Account = { }, alias: null, accountName: null, + filepath: null, + isFedramp: false, + authMode: 'chatgpt', }; describe('normalizeUsage', () => { diff --git a/test/e2e-helpers.ts b/test/e2e-helpers.ts new file mode 100644 index 0000000..2863e8d --- /dev/null +++ b/test/e2e-helpers.ts @@ -0,0 +1,124 @@ +/** + * Shared command-level (end-to-end) harness: runs real command functions + * against a fixture CODEX_HOME and an injected fake transport. + * Not a test file — imported by e2e-weekly.test.ts and idempotency.test.ts. + * @module test/e2e-helpers + */ + +import { access, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { setHttpTransport, TransportError } from '../src/core/http.ts'; +import type { TransportResponse } from '../src/core/http.ts'; +import { resetCommand } from '../src/commands/reset.ts'; +import { + captureOutput, + fakeTransport, + jsonResponse, + oauthAuthFile, + withTempCodexHome, +} from './helpers.ts'; + +/** The live weekly shape: weekly primary, absent secondary. */ +export const WEEKLY_USAGE = { + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { used_percent: 42, limit_window_seconds: 604800, reset_at: 1_755_000_000 }, + secondary_window: null, + }, + rate_limit_reset_credits: { available_count: 2 }, +}; + +export const CREDITS_OK = { + credits: [ + { + id: 'credit-1', + reset_type: 'global', + status: 'available', + granted_at: '2026-08-01T00:00:00Z', + expires_at: '2027-01-01T00:00:00Z', + }, + ], + available_count: 1, +}; + +export function fixtureHome(): Record { + return { + 'accounts/acct.auth.json': JSON.stringify(oauthAuthFile({ plan: 'plus' }), null, 2), + 'accounts/registry.json': JSON.stringify({ + accounts: [ + { + account_key: 'user-456::acct-123', + chatgpt_account_id: 'acct-123', + chatgpt_user_id: 'user-456', + email: 'test@example.com', + alias: 'work', + account_name: null, + plan: null, + }, + ], + }), + }; +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** + * Full two-run redemption: the first consume times out (ambiguous outcome), + * the second succeeds. Returns every consume body sent plus whether the + * pending-redemption record was cleared after success. + */ +export async function e2eResetScenario(): Promise<{ + consumeBodies: string[]; + pendingCleared: boolean; + firstError: unknown; + secondStdout: string; +}> { + let consumeAttempts = 0; + const consumeBodies: string[] = []; + const { transport } = fakeTransport((req): TransportResponse => { + if (req.path.endsWith('/rate-limit-reset-credits/consume')) { + consumeBodies.push(req.body ?? ''); + consumeAttempts += 1; + if (consumeAttempts === 1) throw new TransportError('Request timed out'); + return jsonResponse(200, { code: 'reset', windows_reset: 1 }); + } + if (req.path.endsWith('/rate-limit-reset-credits')) return jsonResponse(200, CREDITS_OK); + return jsonResponse(200, WEEKLY_USAGE); + }); + + let firstError: unknown = null; + let secondStdout = ''; + let pendingCleared = false; + + await withTempCodexHome(fixtureHome(), async (codexHome) => { + setHttpTransport(transport); + try { + await resetCommand({ json: true, yes: true, all: false, query: 'test@example.com' }); + } catch (err) { + firstError = err; + } + // The timed-out send must leave a pending record for the retry to reuse. + const pendingPath = join(codexHome, `pending-redeem.${Buffer.from('acct-123').toString('base64url')}.json`); + const pendingAfterTimeout = await readFile(pendingPath, 'utf-8'); + const parsed = JSON.parse(pendingAfterTimeout) as { redeemRequestId?: string }; + if (typeof parsed.redeemRequestId !== 'string' || consumeBodies.length < 1) { + throw new Error('pending redemption record missing after ambiguous send'); + } + + const { stdout } = await captureOutput(() => + resetCommand({ json: true, yes: true, all: false, query: 'test@example.com' }), + ); + secondStdout = stdout; + pendingCleared = !(await fileExists(pendingPath)); + }).finally(() => setHttpTransport(null)); + + return { consumeBodies, pendingCleared, firstError, secondStdout }; +} diff --git a/test/e2e-weekly.test.ts b/test/e2e-weekly.test.ts new file mode 100644 index 0000000..0806551 --- /dev/null +++ b/test/e2e-weekly.test.ts @@ -0,0 +1,177 @@ +/** + * Command-level end-to-end tests against the live weekly shape: + * weekly (604800s) primary window with an absent secondary. Commands run + * for real against a fixture CODEX_HOME; HTTP is a routed fake transport. + */ + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { setHttpTransport } from '../src/core/http.ts'; +import type { TransportResponse } from '../src/core/http.ts'; +import { listCommand } from '../src/commands/list.ts'; +import { resetCommand } from '../src/commands/reset.ts'; +import { + captureOutput, + fakeTransport, + jsonResponse, + withTempCodexHome, +} from './helpers.ts'; +import { CREDITS_OK, WEEKLY_USAGE, fixtureHome } from './e2e-helpers.ts'; + +afterEach(() => { + setHttpTransport(null); +}); + +describe('list command — weekly primary, absent secondary', () => { + it('renders text output with weekly labels and an unavailable secondary', async () => { + const { transport } = fakeTransport((): TransportResponse => jsonResponse(200, WEEKLY_USAGE)); + setHttpTransport(transport); + const { stdout } = await withTempCodexHome(fixtureHome(), () => + captureOutput(() => listCommand({ json: false })), + ); + + assert.match(stdout, /1\s+work\s+/); + assert.match(stdout, /Weekly limit:\s+\[+\u2588*.*58% left/); + assert.match(stdout, /Secondary limit:\s+unavailable/); + assert.match(stdout, /Lowest left: Weekly 58%, Secondary n\/a/); + assert.match(stdout, /2 reset credits/); + }); + + it('emits machine-readable JSON with the weekly window shape', async () => { + const { transport } = fakeTransport((): TransportResponse => jsonResponse(200, WEEKLY_USAGE)); + setHttpTransport(transport); + const { stdout } = await withTempCodexHome(fixtureHome(), () => + captureOutput(() => listCommand({ json: true })), + ); + + const parsed = JSON.parse(stdout) as Array>; + assert.equal(parsed.length, 1); + const entry = parsed[0]!; + assert.equal(entry['alias'], 'work'); + const usage = entry['usage'] as Record>; + assert.deepEqual(usage['primary'], { + percentUsed: 42, + percentLeft: 58, + windowSeconds: 604800, + resetsAt: 1_755_000_000, + }); + assert.deepEqual(usage['secondary'], { + percentUsed: null, + percentLeft: null, + windowSeconds: null, + resetsAt: null, + }); + assert.deepEqual(entry['credits'], { available: 2 }); + }); +}); + +describe('reset command — weekly shape', () => { + function resetTransport(): { transport: ReturnType['transport'] } { + let consumed = false; + const { transport } = fakeTransport((req): TransportResponse => { + if (req.path.endsWith('/rate-limit-reset-credits/consume')) { + consumed = true; + return jsonResponse(200, { code: 'reset', windows_reset: 1 }); + } + if (req.path.endsWith('/rate-limit-reset-credits')) return jsonResponse(200, CREDITS_OK); + // After the reset the window clears and one credit is spent. + if (consumed) { + return jsonResponse(200, { + rate_limit: { + primary_window: { used_percent: 0, limit_window_seconds: 604800, reset_at: 1_755_000_000 }, + secondary_window: null, + }, + rate_limit_reset_credits: { available_count: 1 }, + }); + } + return jsonResponse(200, WEEKLY_USAGE); + }); + return { transport }; + } + + it('refuses JSON mode without --yes before any network work', async () => { + const { transport, requests } = fakeTransport((): TransportResponse => jsonResponse(200, WEEKLY_USAGE)); + setHttpTransport(transport); + await withTempCodexHome(fixtureHome(), async () => { + await assert.rejects( + resetCommand({ json: true, yes: false, all: false, query: 'test@example.com' }), + /Refusing to redeem/, + ); + }); + assert.equal(requests.length, 0); + }); + + it('redemption with --json --yes reports before/after around the consume', async () => { + const { transport } = resetTransport(); + setHttpTransport(transport); + const { stdout } = await withTempCodexHome(fixtureHome(), () => + captureOutput(() => resetCommand({ json: true, yes: true, all: false, query: 'test@example.com' })), + ); + + const result = JSON.parse(stdout) as Record; + assert.equal(result['outcome'], 'reset'); + assert.equal(result['windowsReset'], 1); + assert.equal(result['account'], 'test@example.com'); + assert.equal(result['creditId'], 'credit-1'); + assert.deepEqual(result['before'], { primary: 42, secondary: null, credits: 2 }); + assert.deepEqual(result['after'], { primary: 0, secondary: null, credits: 1 }); + }); + + it('text mode shows before → after bars and the credit delta', async () => { + const { transport } = resetTransport(); + setHttpTransport(transport); + const { stdout } = await withTempCodexHome(fixtureHome(), () => + captureOutput(() => resetCommand({ json: false, yes: true, all: false, query: 'test@example.com' })), + ); + + assert.match(stdout, /Reset successful for work/); + assert.match(stdout, /Windows reset: 1/); + assert.match(stdout, /Weekly limit:.*→/); + assert.match(stdout, /Credits:\s+2 → 1 left/); + }); + + it('refuses to reset when both usage windows are absent', async () => { + const { transport } = fakeTransport( + (): TransportResponse => jsonResponse(200, { rate_limit: null }), + ); + setHttpTransport(transport); + const { stdout } = await withTempCodexHome(fixtureHome(), () => + captureOutput(() => resetCommand({ json: true, yes: true, all: false, query: 'test@example.com' })), + ); + assert.deepEqual(JSON.parse(stdout), { outcome: 'usageUnavailable', account: 'test@example.com' }); + }); + + it('--all reports noEligibleAccounts when nothing needs a reset', async () => { + const { transport } = fakeTransport((): TransportResponse => jsonResponse(200, WEEKLY_USAGE)); + setHttpTransport(transport); + const { stdout } = await withTempCodexHome(fixtureHome(), () => + captureOutput(() => resetCommand({ json: true, yes: true, all: true })), + ); + assert.deepEqual(JSON.parse(stdout), { outcome: 'noEligibleAccounts' }); + }); + + it('--all resets accounts above the eligibility threshold', async () => { + const hot = { + ...WEEKLY_USAGE, + rate_limit: { + ...WEEKLY_USAGE.rate_limit!, + primary_window: { used_percent: 95, limit_window_seconds: 604800, reset_at: 1_755_000_000 }, + }, + }; + let consumed = false; + const { transport } = fakeTransport((req): TransportResponse => { + if (req.path.endsWith('/rate-limit-reset-credits/consume')) { + consumed = true; + return jsonResponse(200, { code: 'reset', windows_reset: 1 }); + } + if (req.path.endsWith('/rate-limit-reset-credits')) return jsonResponse(200, CREDITS_OK); + return jsonResponse(200, consumed ? WEEKLY_USAGE : hot); + }); + setHttpTransport(transport); + const { stdout } = await withTempCodexHome(fixtureHome(), () => + captureOutput(() => resetCommand({ json: true, yes: true, all: true })), + ); + const parsed = JSON.parse(stdout) as { results: Array<{ outcome: string }> }; + assert.equal(parsed.results[0]?.outcome, 'reset'); + }); +}); diff --git a/test/fixtures/upstream-manifest.json b/test/fixtures/upstream-manifest.json new file mode 100644 index 0000000..b273b6b --- /dev/null +++ b/test/fixtures/upstream-manifest.json @@ -0,0 +1,244 @@ +{ + "source": { + "repo": "openai/codex", + "extractedFrom": [ + "codex-rs/backend-client/src/client/rate_limit_resets.rs", + "codex-rs/backend-client/src/types.rs", + "codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs", + "codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs", + "codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs", + "codex-rs/protocol/src/auth.rs", + "codex-rs/login/src/token_data.rs", + "codex-rs/login/src/auth/storage.rs", + "codex-rs/model-provider/src/bearer_auth_provider.rs", + "codex-rs/login/src/auth/manager.rs", + "codex-rs/login/src/auth/personal_access_token.rs" + ] + }, + "endpoints": { + "usage": "/wham/usage", + "credits": "/wham/rate-limit-reset-credits", + "consume": "/wham/rate-limit-reset-credits/consume" + }, + "consumeRequest": { + "fields": [ + { + "name": "redeem_request_id", + "optionalWhenAbsent": false + }, + { + "name": "credit_id", + "optionalWhenAbsent": true + } + ] + }, + "consumeCodes": { + "casing": "snake_case", + "values": [ + "reset", + "nothing_to_reset", + "no_credit", + "already_redeemed" + ] + }, + "consumeResponse": { + "fields": [ + "code", + "windows_reset" + ] + }, + "creditFields": { + "required": [ + "id", + "reset_type", + "status", + "granted_at" + ], + "optional": [ + "expires_at", + "title", + "description" + ] + }, + "creditsResponse": { + "fields": [ + "credits", + "available_count" + ] + }, + "usageResponse": { + "fields": [ + "plan_type", + "rate_limit", + "credits", + "spend_control", + "additional_rate_limits", + "rate_limit_reached_type", + "rate_limit_reset_credits" + ], + "reachedTypeKinds": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached", + "unknown" + ], + "planTypeValues": [ + "guest", + "free", + "go", + "plus", + "pro", + "prolite", + "free_workspace", + "team", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "business", + "ent26", + "enterprise_cbp_automation", + "enterprise_cbp_usage_based", + "education", + "quorum", + "k12", + "enterprise", + "edu", + "unknown" + ] + }, + "rateLimitDetails": { + "fields": [ + "allowed", + "limit_reached", + "primary_window", + "secondary_window" + ] + }, + "windowFields": [ + "used_percent", + "limit_window_seconds", + "reset_after_seconds", + "reset_at" + ], + "planDisplayNames": { + "free": "Free", + "go": "Go", + "plus": "Plus", + "pro": "Pro", + "prolite": "Pro Lite", + "team": "Team", + "self_serve_business_prolite": "Self Serve Business ProLite", + "self_serve_business_usage_based": "Self Serve Business Usage Based", + "business": "Business", + "ent26": "Enterprise", + "enterprise_cbp_automation": "Enterprise (Automation)", + "enterprise_cbp_usage_based": "Enterprise CBP Usage Based", + "enterprise": "Enterprise", + "hc": "Enterprise", + "education": "Edu", + "edu": "Edu" + }, + "authModes": { + "ApiKey": "apikey", + "Chatgpt": "chatgpt", + "ChatgptAuthTokens": "chatgptAuthTokens", + "Headers": "headers", + "AgentIdentity": "agentIdentity", + "PersonalAccessToken": "personalAccessToken", + "BedrockApiKey": "bedrockApiKey" + }, + "jwtClaims": { + "topLevelEmail": true, + "namespaces": [ + "https://api.openai.com/profile", + "https://api.openai.com/auth" + ], + "authClaimKeys": [ + "chatgpt_plan_type", + "chatgpt_user_id", + "user_id", + "chatgpt_account_id", + "chatgpt_account_is_fedramp" + ], + "profileClaimKeys": [ + "email" + ], + "expClaim": [ + "exp" + ] + }, + "authFile": { + "fields": [ + { + "name": "auth_mode", + "optional": true + }, + { + "name": "OPENAI_API_KEY", + "optional": true + }, + { + "name": "tokens", + "optional": true + }, + { + "name": "last_refresh", + "optional": true + }, + { + "name": "agent_identity", + "optional": true + }, + { + "name": "personal_access_token", + "optional": true + }, + { + "name": "bedrock_api_key", + "optional": true + } + ] + }, + "headers": { + "authorizationScheme": "Bearer", + "accountIdHeader": "ChatGPT-Account-ID", + "literals": [ + { + "name": "X-OpenAI-Fedramp", + "value": "true" + } + ] + }, + "refresh": { + "url": "https://auth.openai.com/oauth/token", + "clientId": "app_EMoamEEZ73f0CkXaXp7hrann", + "grantType": "refresh_token", + "requestFields": [ + "client_id", + "grant_type", + "refresh_token" + ], + "responseFields": [ + "id_token", + "access_token", + "refresh_token" + ], + "failureCodes": [ + "refresh_token_expired", + "refresh_token_reused", + "refresh_token_invalidated" + ] + }, + "patWhoami": { + "baseUrl": "https://auth.openai.com/api/accounts", + "path": "/v1/user-auth-credential/whoami", + "metadataFields": [ + "email", + "chatgpt_user_id", + "chatgpt_account_id", + "chatgpt_plan_type", + "chatgpt_account_is_fedramp" + ] + } +} diff --git a/test/format.test.ts b/test/format.test.ts index 6a4add8..996b7c7 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -168,6 +168,20 @@ describe('planDisplayName', () => { assert.strictEqual(planDisplayName('prolite'), 'Pro Lite'); assert.strictEqual(planDisplayName('custom_plan'), 'Custom Plan'); }); + + it('uses official upstream names for enterprise-tier plans', () => { + assert.strictEqual(planDisplayName('ent26'), 'Enterprise'); + assert.strictEqual(planDisplayName('enterprise_cbp_automation'), 'Enterprise (Automation)'); + assert.strictEqual(planDisplayName('enterprise_cbp_usage_based'), 'Enterprise CBP Usage Based'); + assert.strictEqual(planDisplayName('self_serve_business_prolite'), 'Self Serve Business ProLite'); + assert.strictEqual(planDisplayName('education'), 'Edu'); + assert.strictEqual(planDisplayName('hc'), 'Enterprise'); + }); + + it('falls back to title-casing for values upstream has not catalogued', () => { + assert.strictEqual(planDisplayName('brand_new_tier'), 'Brand New Tier'); + assert.strictEqual(planDisplayName(' plus '), 'Plus'); + }); }); describe('planBadge', () => { diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..3fe99c2 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,175 @@ +/** + * Shared fixtures and harness for codex-reset tests. + * @module test/helpers + */ + +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Account, AuthFile } from '../src/core/types.ts'; +import type { TransportRequest, TransportResponse } from '../src/core/http.ts'; + +/** Minimal unsigned JWT for claim-carrying tokens. */ +export function makeJwt(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `${header}.${payload}.signature`; +} + +export interface OauthFixtureOptions { + email?: string; + accountId?: string; + plan?: string; + fedramp?: boolean; + /** exp claim (unix seconds) for the access token. */ + accessExp?: number; + /** exp claim for the id token (defaults to far future). */ + idExp?: number; + refreshToken?: string; +} + +const AUTH_NS = 'https://api.openai.com/auth'; + +/** ChatGPT-OAuth auth.json fixture with realistic JWT claims. */ +export function oauthAuthFile(opts: OauthFixtureOptions = {}): AuthFile { + const idClaims: Record = { + email: opts.email ?? 'test@example.com', + [AUTH_NS]: { + chatgpt_account_id: opts.accountId ?? 'acct-123', + chatgpt_plan_type: opts.plan ?? 'plus', + chatgpt_user_id: 'user-456', + ...(opts.fedramp ? { chatgpt_account_is_fedramp: true } : {}), + }, + exp: opts.idExp ?? 4_102_444_800, + }; + const accessClaims: Record = { + exp: opts.accessExp ?? 4_102_444_800, + }; + return { + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + access_token: makeJwt(accessClaims), + refresh_token: opts.refreshToken ?? 'refresh-token-1', + id_token: makeJwt(idClaims), + account_id: opts.accountId ?? 'acct-123', + }, + last_refresh: '2026-08-01T00:00:00Z', + }; +} + +/** An Account matching oauthAuthFile(). */ +export function oauthAccount(opts: OauthFixtureOptions = {}, filepath: string | null = null): Account { + const authFile = oauthAuthFile(opts); + return { + email: opts.email ?? 'test@example.com', + planType: opts.plan ?? 'plus', + accountId: opts.accountId ?? 'acct-123', + authFile, + alias: null, + accountName: null, + filepath, + isFedramp: opts.fedramp === true, + authMode: 'chatgpt', + }; +} + +/** Personal-access-token auth.json fixture. */ +export function patAuthFile(pat = 'pat-token-1'): AuthFile { + return { + auth_mode: 'personalAccessToken', + OPENAI_API_KEY: null, + personal_access_token: pat, + last_refresh: null, + }; +} + +/** Recorded request available to handlers and assertions. */ +export interface RecordedRequest extends TransportRequest { + bodyText?: string; +} + +export type RouteHandler = ( + req: RecordedRequest, +) => TransportResponse | Promise; + +/** JSON response helper. */ +export function jsonResponse(status: number, body: unknown, headers: Record = {}): TransportResponse { + return { status, headers, bodyText: JSON.stringify(body) }; +} + +/** Build a recording fake transport. */ +export function fakeTransport(handler: RouteHandler) { + const requests: RecordedRequest[] = []; + const transport = async (req: TransportRequest): Promise => { + const recorded: RecordedRequest = { ...req, bodyText: req.body }; + requests.push(recorded); + return handler(recorded); + }; + return { transport, requests }; +} + +/** Capture everything a function writes to stdout (and optionally stderr). */ +export async function captureOutput( + fn: () => Promise, +): Promise<{ stdout: string; stderr: string; result: T }> { + const outChunks: string[] = []; + const errChunks: string[] = []; + const origOut = process.stdout.write.bind(process.stdout); + const origErr = process.stderr.write.bind(process.stderr); + (process.stdout as unknown as { write: (s: string) => boolean }).write = (s: string) => { + outChunks.push(typeof s === 'string' ? s : s.toString()); + return true; + }; + (process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => { + errChunks.push(typeof s === 'string' ? s : s.toString()); + return true; + }; + try { + const result = await fn(); + return { stdout: outChunks.join(''), stderr: errChunks.join(''), result }; + } finally { + (process.stdout as unknown as { write: typeof origOut }).write = origOut; + (process.stderr as unknown as { write: typeof origErr }).write = origErr; + } +} + +/** Run a function with CODEX_HOME pointed at a populated temp dir. */ +export async function withTempCodexHome( + files: Record, + fn: (codexHome: string) => Promise, +): Promise { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-reset-test-')); + const prev = process.env['CODEX_HOME']; + try { + for (const [relPath, content] of Object.entries(files)) { + const fullPath = join(codexHome, relPath); + await mkdir(join(fullPath, '..'), { recursive: true }); + await writeFile(fullPath, content); + } + process.env['CODEX_HOME'] = codexHome; + return await fn(codexHome); + } finally { + if (prev === undefined) delete process.env['CODEX_HOME']; + else process.env['CODEX_HOME'] = prev; + await rm(codexHome, { recursive: true, force: true }); + } +} + +/** Run a function with environment variables set, restoring them after. */ +export async function withEnv(vars: Record, fn: () => Promise): Promise { + const prev: Record = {}; + try { + for (const [key, value] of Object.entries(vars)) { + prev[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + return await fn(); + } finally { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} diff --git a/test/http-transport.test.ts b/test/http-transport.test.ts new file mode 100644 index 0000000..1175073 --- /dev/null +++ b/test/http-transport.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for the real production transport (nodeHttpTransport) against a live + * localhost server. Every networked invocation of the CLI goes through this + * code; the injected-transport tests elsewhere cannot catch regressions here. + * Skips gracefully when local sockets are unavailable (e.g. hardened CI). + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { nodeHttpTransport, TransportError } from '../src/core/http.ts'; + +interface Served { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: string; +} + +let server: http.Server; +let base: { protocol: 'http:'; hostname: string; port: number }; + +before(async () => { + server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c: Buffer) => (body += c.toString())); + req.on('end', () => { + if (req.url?.endsWith('/slow')) { + return; // never respond + } + if (req.url?.endsWith('/teapot')) { + res.statusCode = 418; + res.end("short and stout"); + return; + } + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '12'); + res.end( + JSON.stringify({ served: { method: req.method, url: req.url, headers: req.headers, body } }), + ); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address() as AddressInfo; + base = { protocol: 'http:', hostname: addr.address, port: addr.port }; +}); + +after(() => { + server.close(); +}); + +function servedBody(bodyText: string): { served: Served } { + return JSON.parse(bodyText) as { served: Served }; +} + +describe('nodeHttpTransport (production path)', () => { + it('delivers method, path, headers, and body to the server', async () => { + const res = await nodeHttpTransport({ + method: 'POST', + ...base, + path: '/backend-api/wham/rate-limit-reset-credits/consume', + headers: { + Authorization: 'Bearer tok', + 'ChatGPT-Account-Id': 'acct-1', + 'X-OpenAI-Fedramp': 'true', + 'Content-Type': 'application/json', + }, + body: '{"redeem_request_id":"r1"}', + timeoutMs: 5_000, + }); + + assert.equal(res.status, 200); + const { served } = servedBody(res.bodyText); + assert.equal(served.method, 'POST'); + assert.equal(served.url, '/backend-api/wham/rate-limit-reset-credits/consume'); + assert.equal(served.headers['authorization'], 'Bearer tok'); + assert.equal(served.headers['chatgpt-account-id'], 'acct-1'); + assert.equal(served.headers['x-openai-fedramp'], 'true'); + assert.equal(served.body, '{"redeem_request_id":"r1"}'); + // Response headers are flattened to lower-cased names. + assert.equal(res.headers['content-type'], 'application/json'); + assert.equal(res.headers['retry-after'], '12'); + }); + + it('reports the HTTP status of error responses', async () => { + const res = await nodeHttpTransport({ + method: 'GET', + ...base, + path: '/teapot', + headers: {}, + timeoutMs: 5_000, + }); + assert.equal(res.status, 418); + assert.equal(res.bodyText, 'short and stout'); + }); + + it('destroys the request on timeout and rejects with TransportError', async () => { + const t0 = Date.now(); + await assert.rejects( + nodeHttpTransport({ + method: 'GET', + ...base, + path: '/slow', + headers: {}, + timeoutMs: 250, + }), + (err: unknown) => { + assert.ok(err instanceof TransportError); + assert.match(err.message, /timed out/i); + return true; + }, + ); + assert.ok(Date.now() - t0 < 5_000, 'timeout must fire near the configured deadline'); + }); +}); diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts new file mode 100644 index 0000000..3629e93 --- /dev/null +++ b/test/idempotency.test.ts @@ -0,0 +1,112 @@ +/** + * Idempotent-consume tests: the redeem_request_id is persisted before the + * POST, reused when retrying an unresolved send, and cleared only on a + * definitive outcome (mirrors upstream TUI idempotency-key semantics). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { access } from 'node:fs/promises'; +import { + clearPendingRedemption, + isAmbiguousConsumeFailure, + isReusablePending, + loadPendingRedemption, + REUSE_WINDOW_MS, + savePendingRedemption, +} from '../src/core/idempotency.ts'; +import { ApiError } from '../src/utils/errors.ts'; +import { e2eResetScenario } from './e2e-helpers.ts'; + +describe('pending redemption persistence', () => { + it('round-trips save/load and clear removes the record', async () => { + const home = `/tmp/codex-reset-pending-${process.pid}-${Date.now()}`; + const pending = { + redeemRequestId: 'r-1', + accountId: 'acct-123', + creditId: 'credit-1', + savedAt: new Date().toISOString(), + }; + await savePendingRedemption(home, pending); + assert.deepEqual(await loadPendingRedemption(home, 'acct-123'), pending); + + await clearPendingRedemption(home, 'acct-123'); + assert.equal(await loadPendingRedemption(home, 'acct-123'), null); + const encoded = Buffer.from('acct-123').toString('base64url'); + await assert.rejects(access(`${home}/pending-redeem.${encoded}.json`)); + }); + + it('encodes filenames injectively so distinct account ids never collide', async () => { + const { readdir, rm } = await import('node:fs/promises'); + const home = `/tmp/codex-reset-pending-${process.pid}-${Date.now()}`; + await savePendingRedemption(home, { + redeemRequestId: 'r-1', + accountId: 'acct:1', + creditId: null, + savedAt: new Date().toISOString(), + }); + await savePendingRedemption(home, { + redeemRequestId: 'r-2', + accountId: 'acct/1', + creditId: null, + savedAt: new Date().toISOString(), + }); + try { + const files = (await readdir(home)).filter((f) => f.startsWith('pending-redeem.')); + assert.equal(files.length, 2); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('returns null for a corrupted pending file', async () => { + const home = `/tmp/codex-reset-pending-${process.pid}-${Date.now()}`; + const { writeFile, mkdir, rm } = await import('node:fs/promises'); + await mkdir(home, { recursive: true }); + await writeFile(`${home}/pending-redeem.acct-123.json`, 'not json', 'utf-8'); + try { + assert.equal(await loadPendingRedemption(home, 'acct-123'), null); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('reuses only fresh pending redemptions for the same account and credit', () => { + const now = Date.now(); + const pending = { + redeemRequestId: 'r-1', + accountId: 'acct-123', + creditId: 'credit-1', + savedAt: new Date(now - 1000).toISOString(), + }; + assert.equal(isReusablePending(pending, 'acct-123', 'credit-1', now), true); + assert.equal(isReusablePending(pending, 'acct-999', 'credit-1', now), false); + assert.equal(isReusablePending(pending, 'acct-123', 'credit-2', now), false); + assert.equal(isReusablePending(pending, 'acct-123', null, now), false); + + const stale = { ...pending, savedAt: new Date(now - REUSE_WINDOW_MS - 1).toISOString() }; + assert.equal(isReusablePending(stale, 'acct-123', 'credit-1', now), false); + }); + + it('classifies only unresolved-outcome failures as ambiguous', () => { + assert.equal(isAmbiguousConsumeFailure(new ApiError('Request timed out', 0)), true); + assert.equal(isAmbiguousConsumeFailure(new ApiError('unknown result code', 200)), true); + assert.equal(isAmbiguousConsumeFailure(new ApiError('backend', 500)), true); + assert.equal(isAmbiguousConsumeFailure(new ApiError('bad request', 400)), false); + assert.equal(isAmbiguousConsumeFailure(new ApiError('unauthorized', 401)), false); + assert.equal(isAmbiguousConsumeFailure(new ApiError('conflict', 409)), false); + assert.equal(isAmbiguousConsumeFailure(new ApiError('rate limited', 429)), false); + assert.equal(isAmbiguousConsumeFailure(new Error('not an ApiError')), false); + }); +}); + +describe('idempotent retry across CLI invocations', () => { + it('reuses the same redeem_request_id after a timeout and clears it on success', async () => { + const { consumeBodies, pendingCleared } = await e2eResetScenario(); + assert.equal(consumeBodies.length, 2); + const first = JSON.parse(consumeBodies[0]!) as { redeem_request_id: string }; + const second = JSON.parse(consumeBodies[1]!) as { redeem_request_id: string }; + assert.equal(second.redeem_request_id, first.redeem_request_id); + assert.equal(pendingCleared, true); + }); +}); diff --git a/test/upstream-contract.test.ts b/test/upstream-contract.test.ts new file mode 100644 index 0000000..4ce7bcd --- /dev/null +++ b/test/upstream-contract.test.ts @@ -0,0 +1,325 @@ +/** + * Upstream wire-contract tests. Every assertion is driven by + * test/fixtures/upstream-manifest.json, which is generated from a pinned + * openai/codex checkout by tools/extract-upstream-manifest.mjs — never + * hand-edited. When CODEX_UPSTREAM_DIR (or /tmp/codex-upstream) exists, the + * manifest is also re-extracted and compared live, so local drift checkouts + * fail here immediately. + */ + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import manifest from './fixtures/upstream-manifest.json' with { type: 'json' }; +import { setHttpTransport } from '../src/core/http.ts'; +import { consumeCredit, getCredits, getUsage, normalizeUsage, normalizeConsumeResponse, resolveBaseUrl } from '../src/core/api.ts'; +import { refreshAccessToken, fetchPatMetadata, accessTokenIsExpired } from '../src/core/auth.ts'; +import { discoverAccounts, extractIdentity } from '../src/core/accounts.ts'; +import { ApiError } from '../src/utils/errors.ts'; +import { planDisplayName } from '../src/utils/format.ts'; +import { fakeTransport, jsonResponse, makeJwt, oauthAccount, withTempCodexHome, withEnv } from './helpers.ts'; + +afterEach(() => { + setHttpTransport(null); +}); + +describe('manifest integrity', () => { + it('is generated from the expected upstream surfaces', () => { + assert.equal(manifest.source.repo, 'openai/codex'); + for (const rel of manifest.source.extractedFrom) { + assert.match(rel, /^codex-rs\//); + } + }); + + it('matches a live extraction when an upstream checkout is available', async () => { + const dir = process.env['CODEX_UPSTREAM_DIR'] ?? '/tmp/codex-upstream'; + if (!existsSync(dir)) return; // drift check runs where the checkout exists + const { extractManifest } = await import('../tools/extract-upstream-manifest.mjs'); + const live = extractManifest(dir); + assert.deepEqual(live, manifest); + }); +}); + +describe('endpoints', () => { + it('requests exactly the manifest ChatGptApi paths under the default base', async () => { + // Pin the env: this test must hold regardless of ambient CODEX_RESET_BASE_URL. + await withEnv({ CODEX_RESET_BASE_URL: undefined }, async () => { + assert.equal(resolveBaseUrl().hostname, 'chatgpt.com'); + const base = resolveBaseUrl().pathname.replace(/\/+$/, ''); + // Pin the absolute production URLs literally. If upstream ever moves, + // the manifest changes and this forces a human to acknowledge the new + // location instead of base+endpoint cancelling each other out. + assert.equal(`https://chatgpt.com${base}${manifest.endpoints.usage}`, 'https://chatgpt.com/backend-api/wham/usage'); + assert.equal(`https://chatgpt.com${base}${manifest.endpoints.credits}`, 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits'); + assert.equal(`https://chatgpt.com${base}${manifest.endpoints.consume}`, 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume'); + + const paths: string[] = []; + const { transport } = fakeTransport((req) => { + paths.push(req.path); + if (req.method === 'POST') return jsonResponse(200, { code: 'reset', windows_reset: 1 }); + if (req.path.endsWith('/rate-limit-reset-credits')) { + return jsonResponse(200, { credits: [], available_count: 0 }); + } + return jsonResponse(200, { rate_limit: null }); + }); + setHttpTransport(transport); + const account = oauthAccount(); + await getUsage(account); + await getCredits(account); + await consumeCredit(account, 'r1'); + assert.equal(paths[0], `${base}${manifest.endpoints.usage}`); + assert.equal(paths[1], `${base}${manifest.endpoints.credits}`); + assert.equal(paths[2], `${base}${manifest.endpoints.consume}`); + setHttpTransport(null); + }); + }); +}); + +describe('consume request and response', () => { + it('body uses exactly the manifest request fields', async () => { + const { transport, requests } = fakeTransport(() => + jsonResponse(200, { code: 'reset', windows_reset: 1 }), + ); + setHttpTransport(transport); + await consumeCredit(oauthAccount(), 'redeem-1', 'credit-1'); + const body = JSON.parse(requests[0]!.body ?? '') as Record; + assert.deepEqual( + Object.keys(body).sort(), + manifest.consumeRequest.fields.map((f) => f.name).sort(), + ); + const creditId = manifest.consumeRequest.fields.find((f) => f.optionalWhenAbsent)!; + assert.equal(body[creditId.name], 'credit-1'); + }); + + it('omits the optional credit_id field when absent', async () => { + const { transport, requests } = fakeTransport(() => + jsonResponse(200, { code: 'reset', windows_reset: 0 }), + ); + setHttpTransport(transport); + await consumeCredit(oauthAccount(), 'redeem-2'); + const body = JSON.parse(requests[0]!.body ?? '') as Record; + assert.equal('credit_id' in body, false); + assert.deepEqual(Object.keys(body), ['redeem_request_id']); + }); + + it('accepts every manifest snake_case result code and rejects unknowns', () => { + assert.equal(manifest.consumeCodes.casing, 'snake_case'); + for (const value of manifest.consumeCodes.values) { + const result = normalizeConsumeResponse({ code: value, windows_reset: 0 }); + assert.ok(result.code, value); + } + assert.throws(() => normalizeConsumeResponse({ code: 'made_up_code' }), ApiError); + }); + + it('response fields match the manifest', () => { + const result = normalizeConsumeResponse({ code: 'reset', windows_reset: 2 }); + assert.deepEqual(Object.keys(result).sort(), [...manifest.consumeResponse.fields].sort()); + assert.equal(result.windows_reset, 2); + }); +}); + +describe('usage payload shape', () => { + it('normalizes every manifest window field', () => { + const window: Record = {}; + for (const field of manifest.windowFields) window[field] = field === 'used_percent' ? 10 : 604800; + window['reset_at'] = 1_755_000_000; + const result = normalizeUsage(oauthAccount(), { + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: window, + secondary_window: window, + } as never, + rate_limit_reset_credits: { available_count: 1 }, + }); + assert.equal(result.primaryPercent, 10); + assert.equal(result.primaryWindowSeconds, 604800); + assert.equal(result.primaryResetAt, 1_755_000_000); + }); + + it('tolerates every additive top-level field the payload may carry', () => { + const additive: Record = {}; + for (const field of manifest.usageResponse.fields) additive[field] = null; + additive['rate_limit'] = null; + const result = normalizeUsage(oauthAccount(), additive as never); + assert.equal(result.primaryPercent, null); + assert.equal(result.availableCredits, 0); + }); + + it('requires the manifest-required credit fields and optional ones default to null', async () => { + const required = manifest.creditFields.required; + const good: Record = Object.fromEntries(required.map((f) => [f, `v-${f}`])); + const { transport } = fakeTransport(() => jsonResponse(200, { credits: [good], available_count: 1 })); + setHttpTransport(transport); + const credits = await getCredits(oauthAccount()); + assert.equal(credits.credits[0]!.title, null); + assert.equal(credits.credits[0]!.expires_at, null); + + const bad = { ...good }; + delete bad[required[0]!]; + const { transport: badTransport } = fakeTransport(() => + jsonResponse(200, { credits: [bad], available_count: 1 }), + ); + setHttpTransport(badTransport); + await assert.rejects(getCredits(oauthAccount()), /invalid credit record/); + }); +}); + +describe('plan display names', () => { + it('matches every official upstream display name', () => { + for (const [raw, display] of Object.entries(manifest.planDisplayNames)) { + assert.equal(planDisplayName(raw), display, raw); + } + }); + + it('falls back gracefully for unknown plan values', () => { + assert.equal(planDisplayName('brand_new_plan'), 'Brand New Plan'); + }); +}); + +describe('auth file and JWT claims', () => { + it('tolerates the complete manifest AuthDotJson field set', async () => { + const authFile: Record = {}; + for (const field of manifest.authFile.fields) { + authFile[field.name] = field.name === 'tokens' ? undefined : null; + } + const idToken = makeJwt({ + email: 'manifest@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct-m', + chatgpt_plan_type: 'plus', + chatgpt_account_is_fedramp: false, + }, + }); + authFile['auth_mode'] = 'chatgpt'; + authFile['tokens'] = { + access_token: 'a', + refresh_token: 'r', + id_token: idToken, + account_id: 'acct-m', + }; + const accounts = await withTempCodexHome( + { 'accounts/m.auth.json': JSON.stringify(authFile) }, + () => discoverAccounts(), + ); + assert.equal(accounts.length, 1); + assert.equal(accounts[0]!.accountId, 'acct-m'); + }); + + it('reads identity from the manifest claim namespaces', () => { + const authKeys = manifest.jwtClaims.authClaimKeys; + const authClaims: Record = { + chatgpt_account_id: 'acct-claims', + chatgpt_plan_type: 'pro', + chatgpt_account_is_fedramp: true, + }; + void authKeys; + const identity = extractIdentity({ + tokens: { + access_token: 'a', + refresh_token: 'r', + id_token: makeJwt({ + email: 'claims@example.com', + 'https://api.openai.com/auth': authClaims, + }), + account_id: null, + }, + }); + assert.equal(identity.accountId, 'acct-claims'); + assert.equal(identity.planType, 'pro'); + assert.equal(identity.isFedramp, true); + }); + + it('uses the manifest profile namespace as the email fallback', () => { + const identity = extractIdentity({ + tokens: { + access_token: 'a', + refresh_token: 'r', + id_token: makeJwt({ 'https://api.openai.com/profile': { email: 'p@example.com' } }), + account_id: 'acct-x', + }, + }); + assert.equal(identity.email, 'p@example.com'); + assert.equal(identity.accountId, 'acct-x'); + }); + + it('uses the exp claim for proactive refresh decisions', () => { + const expired = makeJwt({ exp: 1_000 }); + const valid = makeJwt({ exp: 4_102_444_800 }); + assert.equal(accessTokenIsExpired(expired), true); + assert.equal(accessTokenIsExpired(valid), false); + assert.equal(accessTokenIsExpired('not-a-jwt'), false); + }); +}); + +describe('request headers', () => { + it('uses the manifest bearer scheme, account-id header, and FedRAMP literal', async () => { + assert.equal(manifest.headers.authorizationScheme, 'Bearer'); + const expectedAccountHeader = manifest.headers.accountIdHeader!.toLowerCase(); + const fedramp = manifest.headers.literals.find((h) => /fedramp/i.test(h.name))!; + const { transport, requests } = fakeTransport(() => jsonResponse(200, { rate_limit: null })); + setHttpTransport(transport); + await getUsage(oauthAccount({ fedramp: true })); + const headers = requests[0]!.headers; + assert.match(headers['Authorization'] ?? '', /^Bearer /); + const accountHeader = Object.keys(headers).find((h) => h.toLowerCase() === expectedAccountHeader)!; + assert.equal(headers[accountHeader], 'acct-123'); + const fedrampHeader = Object.keys(headers).find((h) => h.toLowerCase() === fedramp.name.toLowerCase())!; + assert.equal(headers[fedrampHeader], fedramp.value); + }); +}); + +describe('oauth refresh contract', () => { + it('posts the manifest grant to the manifest endpoint', async () => { + const url = new URL(manifest.refresh.url); + const { transport, requests } = fakeTransport(() => + jsonResponse(200, { id_token: 'i', access_token: 'a', refresh_token: 'r2' }), + ); + setHttpTransport(transport); + await withEnv({ CODEX_REFRESH_TOKEN_URL_OVERRIDE: manifest.refresh.url }, () => + refreshAccessToken('rt-1'), + ); + const req = requests[0]!; + assert.equal(req.hostname, url.hostname); + assert.equal(req.path, url.pathname); + const body = JSON.parse(req.body ?? '') as Record; + assert.deepEqual( + Object.keys(body).sort(), + manifest.refresh.requestFields.slice().sort(), + ); + assert.equal(body['grant_type'], manifest.refresh.grantType); + assert.equal(body['client_id'], manifest.refresh.clientId); + }); + + it('classification covers the manifest failure codes', async () => { + for (const code of manifest.refresh.failureCodes) { + const { transport } = fakeTransport(() => jsonResponse(400, { error: { code } })); + setHttpTransport(transport); + const err = (await refreshAccessToken('rt').catch((e: unknown) => e)) as Error; + assert.match(err.message, /sign in again|refresh/i, code); + } + }); +}); + +describe('pat whoami contract', () => { + it('hydrates from the manifest endpoint using its metadata fields', async () => { + const { transport, requests } = fakeTransport(() => + jsonResponse(200, { + email: 'pat@example.com', + chatgpt_user_id: 'u', + chatgpt_account_id: 'acct-p', + chatgpt_plan_type: 'pro', + chatgpt_account_is_fedramp: true, + }), + ); + setHttpTransport(transport); + const metadata = await fetchPatMetadata('pat-1'); + const req = requests[0]!; + const whoamiBase = new URL(manifest.patWhoami.baseUrl); + assert.equal(req.hostname, whoamiBase.hostname); + assert.equal(req.path, `${whoamiBase.pathname}${manifest.patWhoami.path}`); + assert.equal(req.headers['Authorization'], 'Bearer pat-1'); + assert.equal(metadata.chatgpt_account_id, 'acct-p'); + assert.equal(metadata.chatgpt_account_is_fedramp, true); + }); +}); diff --git a/tools/extract-upstream-manifest.mjs b/tools/extract-upstream-manifest.mjs new file mode 100644 index 0000000..e04d3aa --- /dev/null +++ b/tools/extract-upstream-manifest.mjs @@ -0,0 +1,303 @@ +#!/usr/bin/env node +/** + * Extract the semantic wire contract from a pinned openai/codex checkout. + * + * Emits test/fixtures/upstream-manifest.json. The contract test asserts + * codex-reset's behavior against that manifest, so upstream drift shows up + * as a test failure (or a regenerated manifest diff in the drift CI job). + * + * Usage: + * node tools/extract-upstream-manifest.mjs [--src DIR] [--out FILE] + * + * --src defaults to $CODEX_UPSTREAM_DIR or /tmp/codex-upstream and must be a + * checkout of openai/codex containing codex-rs/. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_SRC = process.env.CODEX_UPSTREAM_DIR || '/tmp/codex-upstream'; +const DEFAULT_OUT = 'test/fixtures/upstream-manifest.json'; + +function parseArgs(argv) { + const args = { src: DEFAULT_SRC, out: DEFAULT_OUT }; + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--src') args.src = argv[++i]; + else if (argv[i] === '--out') args.out = argv[++i]; + } + return args; +} + +function read(src, rel) { + const full = path.join(src, rel); + if (!fs.existsSync(full)) { + throw new Error(`missing upstream file: ${full}`); + } + return fs.readFileSync(full, 'utf8'); +} + +/** Slice a Rust item from its declaration to its closing brace. + * `close` selects item-level ('\n}') or fn-inside-impl ('\n }') ends. */ +function blockAfter(text, decl, what, close = '\n}') { + const start = text.indexOf(decl); + if (start === -1) throw new Error(`cannot find ${what ?? decl}`); + const end = text.indexOf(close, start); + if (end === -1) throw new Error(`cannot find end of ${what ?? decl}`); + return text.slice(start, end); +} + +function toSnakeCase(variant) { + return variant.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); +} + +/** Field names of a struct, honoring #[serde(rename)] and flattening. */ +function serdeStructFields(block) { + const fields = []; + let pendingRename = null; + let pendingFlattened = false; + for (const line of block.split('\n')) { + const rename = line.match(/rename\s*=\s*"([^"]+)"/); + if (rename) { + pendingRename = rename[1]; + continue; + } + if (line.includes('#[serde(flatten)]')) { + pendingFlattened = true; + continue; + } + // Struct fields may be private (no `pub`); attrs never look like fields. + const field = line.match(/^\s*(?:pub(?:\([^)]*\))?\s+)?([a-z_][a-z0-9_]*)\s*:/); + if (field) { + fields.push({ + name: pendingRename ?? field[1], + optional: line.includes('Option<') || line.includes('skip_serializing_if'), + flattened: pendingFlattened, + }); + pendingRename = null; + pendingFlattened = false; + } + } + return fields; +} + +function extractManifest(src) { + const manifest = { source: { repo: 'openai/codex', extractedFrom: [] } }; + const track = (rel) => manifest.source.extractedFrom.push(rel); + + // --- Endpoints (backend-client, PathStyle::ChatGptApi) --- + const rlRel = 'codex-rs/backend-client/src/client/rate_limit_resets.rs'; + const rl = read(src, rlRel); + track(rlRel); + const chatgptPaths = [...rl.matchAll(/PathStyle::ChatGptApi\s*=>\s*(?:\{\s*)?format!\(\s*"\{\}\/([^"]+)"/g)].map((m) => m[1]); + if (chatgptPaths.length !== 3) { + throw new Error(`expected 3 ChatGptApi endpoints, found ${chatgptPaths.length}: ${chatgptPaths}`); + } + const bySuffix = Object.fromEntries(chatgptPaths.map((p) => [path.basename(p), `/${p}`])); + manifest.endpoints = { + usage: bySuffix['usage'], + credits: bySuffix['rate-limit-reset-credits'], + consume: bySuffix['consume'], + }; + + // --- Consume request/response + codes + credit details (backend-client types) --- + const typesRel = 'codex-rs/backend-client/src/types.rs'; + const types = read(src, typesRel); + track(typesRel); + + const consumeReq = blockAfter(rl, 'struct ConsumeRateLimitResetCreditRequest', 'consume request struct'); + manifest.consumeRequest = { + fields: serdeStructFields(consumeReq).map((f) => ({ + name: f.name, + optionalWhenAbsent: f.optional, + })), + }; + + const codeEnum = blockAfter(types, 'pub enum ConsumeRateLimitResetCreditCode', 'consume code enum'); + const enumHeader = types.slice(types.indexOf('pub enum ConsumeRateLimitResetCreditCode') - 200, types.indexOf('pub enum ConsumeRateLimitResetCreditCode')); + const snakeRename = /rename_all\s*=\s*"snake_case"/.test(enumHeader); + const variants = [...codeEnum.matchAll(/^\s{4}([A-Z][A-Za-z0-9]*),\s*$/gm)].map((m) => m[1]); + if (variants.length === 0) throw new Error('no consume code variants found'); + manifest.consumeCodes = { + casing: snakeRename ? 'snake_case' : 'verbatim', + values: variants.map((v) => (snakeRename ? toSnakeCase(v) : v)), + }; + + const consumeResp = blockAfter(types, 'pub struct ConsumeRateLimitResetCreditResponse', 'consume response struct'); + manifest.consumeResponse = { fields: serdeStructFields(consumeResp).map((f) => f.name) }; + + const creditDetails = blockAfter(types, 'pub struct RateLimitResetCreditDetails', 'credit details struct'); + const creditFields = serdeStructFields(creditDetails); + manifest.creditFields = { + required: creditFields.filter((f) => !f.optional).map((f) => f.name), + optional: creditFields.filter((f) => f.optional).map((f) => f.name), + }; + const creditList = blockAfter(types, 'pub struct RateLimitResetCreditsDetails', 'credits list struct'); + manifest.creditsResponse = { fields: serdeStructFields(creditList).map((f) => f.name) }; + + // --- Usage payload (openapi models) --- + const payloadRel = 'codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs'; + const payload = read(src, payloadRel); + track(payloadRel); + const statusPayload = blockAfter(payload, 'pub struct RateLimitStatusPayload', 'usage payload struct'); + manifest.usageResponse = { + fields: serdeStructFields(statusPayload).map((f) => f.name), + reachedTypeKinds: [...blockAfter(payload, 'pub enum RateLimitReachedKind', 'reached kind enum').matchAll(/rename\s*=\s*"([^"]+)"/g)].map((m) => m[1]), + planTypeValues: [...blockAfter(payload, 'pub enum PlanType', 'plan type enum').matchAll(/rename\s*=\s*"([^"]+)"/g)].map((m) => m[1]), + }; + + const detailsRel = 'codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs'; + const details = read(src, detailsRel); + track(detailsRel); + const statusDetails = blockAfter(details, 'pub struct RateLimitStatusDetails', 'rate limit details struct'); + manifest.rateLimitDetails = { fields: serdeStructFields(statusDetails).map((f) => f.name) }; + + const windowRel = 'codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs'; + const window = read(src, windowRel); + track(windowRel); + const windowSnapshot = blockAfter(window, 'pub struct RateLimitWindowSnapshot', 'window snapshot struct'); + manifest.windowFields = serdeStructFields(windowSnapshot).map((f) => f.name); + + const usageWithCredits = blockAfter(types, 'struct RateLimitStatusWithResetCredits', 'usage-with-credits struct'); + manifest.usageResponse.fields.push( + ...serdeStructFields(usageWithCredits) + .filter((f) => !f.flattened) + .map((f) => f.name), + ); + + // --- Plan display names (protocol) --- + const authRel = 'codex-rs/protocol/src/auth.rs'; + const auth = read(src, authRel); + track(authRel); + const fromRaw = blockAfter(auth, 'pub fn from_raw_value', 'from_raw_value', '\n }'); + const rawToVariant = new Map(); + // Accept both inline arms and rustfmt-wrapped block arms: + // "raw" => Self::Known(KnownPlan::V) + // "raw" => { Self::Known(KnownPlan::V) } + for (const m of fromRaw.matchAll( + /((?:"[^"]+"\s*\|\s*)*"[^"]+")\s*=>\s*(?:\{\s*)?Self::Known\(KnownPlan::(\w+)\)/g, + )) { + for (const raw of [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1])) { + rawToVariant.set(raw, m[2]); + } + } + const displayName = blockAfter(auth, 'pub fn display_name', 'display_name', '\n }'); + const variantToName = new Map(); + for (const m of displayName.matchAll(/Self::(\w+)\s*=>\s*"([^"]*)"/g)) { + variantToName.set(m[1], m[2]); + } + manifest.planDisplayNames = Object.fromEntries( + [...rawToVariant.entries()].map(([raw, variant]) => [raw, variantToName.get(variant) ?? null]), + ); + // KnownPlan currently has 14 variants with 15+ raw aliases; fewer means the + // arm regex silently dropped block-formatted arms again. + if (Object.keys(manifest.planDisplayNames).length < 14) { + throw new Error( + `suspiciously few plan display names extracted (${Object.keys(manifest.planDisplayNames).length}) — arm regex likely dropped entries`, + ); + } + + const authModeEnumDecl = auth.indexOf('pub enum AuthMode'); + const authModeAttrs = auth.slice(Math.max(0, authModeEnumDecl - 300), authModeEnumDecl); + const authModeRenameAll = authModeAttrs.match(/rename_all\s*=\s*"([^"]+)"/)?.[1] ?? null; + const authModeBlock = blockAfter(auth, 'pub enum AuthMode', 'auth mode enum'); + const modes = {}; + let pendingModeRename = null; + for (const line of authModeBlock.split('\n')) { + const rename = line.match(/rename\s*=\s*"([^"]+)"/); + if (rename) { + pendingModeRename = rename[1]; + continue; + } + const variant = line.match(/^\s{4}([A-Z][A-Za-z0-9]*),\s*$/); + if (variant) { + const v = variant[1]; + modes[v] = pendingModeRename ?? (authModeRenameAll === 'lowercase' ? v.toLowerCase() : v); + pendingModeRename = null; + } + } + manifest.authModes = modes; + + // --- JWT claim layout (login token_data) --- + const tokenRel = 'codex-rs/login/src/token_data.rs'; + const token = read(src, tokenRel); + track(tokenRel); + const idClaims = blockAfter(token, 'struct IdClaims', 'IdClaims'); + manifest.jwtClaims = { + topLevelEmail: /email:\s*Option/.test(blockAfter(token, 'struct IdClaims', 'IdClaims')), + namespaces: [...idClaims.matchAll(/rename\s*=\s*"([^"]+)"/g)].map((m) => m[1]), + authClaimKeys: serdeStructFields(blockAfter(token, 'struct AuthClaims', 'AuthClaims')).map((f) => f.name), + profileClaimKeys: serdeStructFields(blockAfter(token, 'struct ProfileClaims', 'ProfileClaims')).map((f) => f.name), + expClaim: serdeStructFields(blockAfter(token, 'struct StandardJwtClaims', 'StandardJwtClaims')).map((f) => f.name), + }; + + // --- Auth-file schema (login storage) --- + const storageRel = 'codex-rs/login/src/auth/storage.rs'; + const storage = read(src, storageRel); + track(storageRel); + manifest.authFile = { + fields: serdeStructFields(blockAfter(storage, 'pub struct AuthDotJson', 'AuthDotJson')).map((f) => ({ + name: f.name, + optional: f.optional, + })), + }; + + // --- Request headers (model-provider bearer provider) --- + const bearerRel = 'codex-rs/model-provider/src/bearer_auth_provider.rs'; + const bearer = read(src, bearerRel); + track(bearerRel); + manifest.headers = { + authorizationScheme: bearer.includes('format!("Bearer {token}")') ? 'Bearer' : null, + accountIdHeader: bearer.match(/headers\.insert\("([^"]*Account[^"]*)",/)?.[1] ?? null, + literals: [...bearer.matchAll(/headers\.insert\("([^"]+)",\s*HeaderValue::from_static\("([^"]+)"\)\)/g)].map((m) => ({ name: m[1], value: m[2] })), + }; + + // --- OAuth refresh (login manager) --- + const managerRel = 'codex-rs/login/src/auth/manager.rs'; + const manager = read(src, managerRel); + track(managerRel); + const refreshUrl = manager.match(/REFRESH_TOKEN_URL:\s*&str\s*=\s*"([^"]+)"/)?.[1]; + const clientId = manager.match(/pub const CLIENT_ID:\s*&str\s*=\s*"([^"]+)"/)?.[1]; + const grantType = manager.match(/grant_type:\s*"([^"]+)"/)?.[1]; + const refreshReq = blockAfter(manager, 'struct RefreshRequest', 'RefreshRequest'); + const refreshResp = blockAfter(manager, 'struct RefreshResponse', 'RefreshResponse'); + manifest.refresh = { + url: refreshUrl ?? null, + clientId: clientId ?? null, + grantType: grantType ?? null, + requestFields: serdeStructFields(refreshReq).map((f) => f.name), + responseFields: serdeStructFields(refreshResp).map((f) => f.name), + failureCodes: [...manager.matchAll(/Some\("(refresh_token_\w+)"\)/g)].map((m) => m[1]), + }; + if (!refreshUrl || !clientId || grantType !== 'refresh_token') { + throw new Error('refresh contract extraction incomplete'); + } + + // --- PAT whoami (login personal_access_token) --- + const patRel = 'codex-rs/login/src/auth/personal_access_token.rs'; + const pat = read(src, patRel); + track(patRel); + manifest.patWhoami = { + baseUrl: pat.match(/PROD_AUTHAPI_BASE_URL:\s*&str\s*=\s*"([^"]+)"/)?.[1] ?? null, + path: pat.match(/WHOAMI_PATH:\s*&str\s*=\s*"([^"]+)"/)?.[1] ?? null, + metadataFields: serdeStructFields(blockAfter(pat, 'struct PersonalAccessTokenMetadata', 'PAT metadata')).map((f) => f.name), + }; + + return manifest; +} + +export { extractManifest, DEFAULT_SRC, DEFAULT_OUT }; + +const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); +if (isDirectRun) { + const args = parseArgs(process.argv); + try { + const manifest = extractManifest(args.src); + const out = path.resolve(args.out); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + console.log(`upstream manifest written to ${out} (${manifest.source.extractedFrom.length} source files)`); + } catch (err) { + console.error(`extract-upstream-manifest: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } +} From ee84d37808e055a515c21e60b391a981b29bf864 Mon Sep 17 00:00:00 2001 From: Can Date: Sun, 16 Aug 2026 02:42:33 +0300 Subject: [PATCH 2/2] ci: add non-blocking upstream-drift workflow and document auth modes Weekly (and manual) job regenerates the upstream manifest from a sparse openai/codex clone, uploads the drift diff as an artifact, and opens or updates an issue on drift. When the check itself fails (extractor crash on an upstream refactor), a separate always()+failure() step opens an upstream-drift-broken issue so drift tracking cannot die silently. The job is continue-on-error and never runs on pull requests. README: supported auth modes table (chatgpt / personalAccessToken via whoami / apikey+agent+bedrock skipped with warnings), file-storage-only credential model (keyring not read), token refresh + rotation behavior, FedRAMP routing header, precise idempotent-redemption guarantee with its limits, env overrides, and manifest maintenance instructions. --- .github/workflows/upstream-drift.yml | 140 +++++++++++++++++++++++++++ README.md | 80 ++++++++++++++- 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/upstream-drift.yml diff --git a/.github/workflows/upstream-drift.yml b/.github/workflows/upstream-drift.yml new file mode 100644 index 0000000..60c5b56 --- /dev/null +++ b/.github/workflows/upstream-drift.yml @@ -0,0 +1,140 @@ +name: upstream-drift + +# Detects wire-contract drift between this tool and openai/codex HEAD by +# regenerating test/fixtures/upstream-manifest.json from a fresh sparse clone +# and comparing. Report-only: this workflow never gates pull requests, and a +# failing or drifted run does not block anything — it opens an issue instead. + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' # Mondays 06:00 UTC + +permissions: + contents: read + issues: write + +jobs: + drift-check: + runs-on: ubuntu-latest + # Non-blocking by construction: report drift, never fail the repo red. + continue-on-error: true + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Sparse-clone openai/codex (codex-rs only) + run: | + git clone --filter=blob:none --no-checkout --depth 1 \ + https://github.com/openai/codex.git /tmp/codex-upstream + cd /tmp/codex-upstream + git sparse-checkout set codex-rs + git checkout + + - name: Regenerate the upstream manifest + run: npm run manifest -- --src /tmp/codex-upstream + + - name: Detect drift + id: drift + run: | + if git diff --exit-code -- test/fixtures/upstream-manifest.json; then + echo "drifted=false" >> "$GITHUB_OUTPUT" + else + echo "drifted=true" >> "$GITHUB_OUTPUT" + git diff -- test/fixtures/upstream-manifest.json | head -200 > /tmp/drift.diff + fi + + - name: Upload drifted manifest + if: steps.drift.outputs.drifted == 'true' + uses: actions/upload-artifact@v4 + with: + name: upstream-manifest-drift + path: | + test/fixtures/upstream-manifest.json + /tmp/drift.diff + + - name: Open or update a drift issue + if: steps.drift.outputs.drifted == 'true' + uses: actions/github-script@v7 + with: + script: | + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'upstream-drift', + }); + const body = [ + 'The weekly upstream-drift check found wire-contract changes in `openai/codex`.', + '', + 'Next steps:', + '1. Pull the `upstream-manifest-drift` artifact (regenerated manifest + diff).', + '2. Review the diff against `src/` behavior; the contract tests in', + ' `test/upstream-contract.test.ts` describe each manifest section.', + '3. Regenerate locally: `git clone --filter=blob:none --depth 1 https://github.com/openai/codex.git /tmp/codex-upstream && npm run manifest`', + '4. Update the tool or the manifest, then land both together.', + ].join('\n'); + if (issues.length > 0) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issues[0].number, + body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'Upstream wire-contract drift detected (openai/codex)', + body, + labels: ['upstream-drift'], + }); + } + + # The check itself failing (extractor crash on an upstream refactor, npm + # ci failure, clone failure) must page someone — otherwise drift tracking + # dies silently while the workflow stays green via continue-on-error. + - name: Open or update an issue when the check itself fails + if: always() && failure() + uses: actions/github-script@v7 + with: + script: | + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'upstream-drift-broken', + }); + const body = [ + 'The weekly upstream-drift workflow **failed to complete** — drift is currently untracked.', + '', + `Failing run: ${runUrl}`, + '', + 'Most likely cause: openai/codex refactored files the manifest extractor parses', + '(`tools/extract-upstream-manifest.mjs`). Update the extractor, regenerate the', + 'manifest (`npm run manifest -- --src `), and confirm', + '`npm test` passes, then close this issue.', + ].join('\n'); + if (issues.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issues[0].number, + body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'upstream-drift workflow is failing — drift untracked', + body, + labels: ['upstream-drift-broken'], + }); + } diff --git a/README.md b/README.md index 279daec..63c2abb 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,18 @@ Output shows before/after comparison: | `NO_COLOR=1` | Disable colored output | | `FORCE_COLOR=1` | Force colored output | +Environment overrides used mostly by tests and local development: +`CODEX_RESET_BASE_URL` (ChatGPT backend base, default +`https://chatgpt.com/backend-api`), plus the upstream-honored +`CODEX_REFRESH_TOKEN_URL_OVERRIDE`, `CODEX_APP_SERVER_LOGIN_CLIENT_ID`, and +`CODEX_AUTHAPI_BASE_URL`. + ## How it works 1. **Account discovery**: Reads codex-auth multi-account files and falls back to official Codex CLI/Desktop `auth.json` 2. **Usage check**: Calls `GET /backend-api/wham/usage` to fetch current rate-limit windows; missing windows are displayed as unavailable 3. **Credit listing**: Calls `GET /backend-api/wham/rate-limit-reset-credits` to list individual credits, expiry, and reset scope -4. **Credit consumption**: Calls `POST /backend-api/wham/rate-limit-reset-credits/consume` with a UUID `redeem_request_id` and the selected `credit_id` when the backend provides one +4. **Credit consumption**: Calls `POST /backend-api/wham/rate-limit-reset-credits/consume` with a UUID `redeem_request_id` and the selected `credit_id` when the backend provides one. The idempotency key is persisted before the request so a retry of the *same* redemption reuses it (see [Idempotent redemption](#idempotent-redemption)). All requests use HTTPS with your existing OAuth access token. No credentials are stored or logged. @@ -152,6 +158,60 @@ own copies under `~/.codex-switch/profiles//auth.json`; those files are not treated as source of truth because they can go stale after codex-auth refreshes tokens. +## Supported auth modes and credential storage + +The auth-file schema mirrors upstream `AuthDotJson` (openai/codex +`login/src/auth/storage.rs`), which is also what codex-auth snapshots verbatim. + +| Auth mode | Behavior | +| --------- | -------- | +| `chatgpt` (OAuth tokens) | Fully supported. Tokens are refreshed automatically (see below) and rotated tokens are written back to the same file. | +| `personalAccessToken` | Supported. The token is verified against `auth.openai.com …/user-auth-credential/whoami` (the same call upstream makes) to resolve email, account id, plan, and FedRAMP status, then used as the Bearer credential. | +| `apikey`, `agentIdentity`, `bedrockApiKey` | Skipped with a warning — these have no ChatGPT rate limits to inspect or reset. | +| Missing/unreadable credentials | Skipped with a warning; never crashes discovery. | + +**Credential storage is file-based only.** If you configured the official Codex +CLI to store credentials in the OS keyring (`storage_mode = "keyring"` or +`preferred_auth_mode` keyring settings in upstream Codex), `codex-reset` will +not find them — it reads `auth.json` / `accounts/*.auth.json` only. Keep at +least one file-based account, or run `codex login` with file storage. + +**Token refresh.** When the stored access token is expired (JWT `exp` claim) or +the backend answers `401`, codex-reset performs the upstream refresh grant +(`POST https://auth.openai.com/oauth/token`, client id +`app_EMoamEEZ73f0CkXaXp7hrann`, overridable via +`CODEX_APP_SERVER_LOGIN_CLIENT_ID` / `CODEX_REFRESH_TOKEN_URL_OVERRIDE`) and +persists any rotated tokens before retrying the request once. If the refresh +token itself is expired, revoked, or reused, you are told to sign in again. + +**FedRAMP.** Accounts whose id_token carries `chatgpt_account_is_fedramp: true` +send `X-OpenAI-Fedramp: true` on every backend request, matching upstream +routing. + +## Idempotent redemption + +Consuming a credit is destructive, and a network timeout after the server +processed the request risks spending a second credit on retry. The redemption +id (`redeem_request_id`) is written to `{CODEX_HOME}/pending-redeem..json` +**before** the POST and kept until the outcome is resolved: + +- a 2xx response with a known result code, or a 4xx rejection → record cleared +- timeout / connection reset / 5xx / a 2xx body with an *unknown* result code + (consumed but unreadable) → record kept as unresolved +- rerunning `reset` for the **same account and same credit** within 24h reuses + the original id (surfacing `retrying unresolved redemption with its original + request id`), so the server's idempotency deduplicates the retry + +Limits of the guarantee, both announced on stderr when they occur: if the retry +selects a **different credit** (e.g. the original one is no longer listed) or +the unresolved record is older than 24h, a fresh id is minted — with the +warning `a previous redemption attempt did not complete and may already have +used a credit`. The server-side `nothing_to_reset`/`already_redeemed` codes +usually neutralize such a retry, but the tool cannot rule out a second spend, +which is why it warns instead of staying silent. This mirrors the +idempotency-key retry semantics of the official TUI's +`/usage → Redeem usage limit reset` flow. + ## Exit codes | Code | Meaning | @@ -175,6 +235,24 @@ See [SECURITY.md](./SECURITY.md) for vulnerability reporting and security practi See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and PR process. +### Maintaining the upstream contract + +`test/fixtures/upstream-manifest.json` is the machine-readable wire contract, +generated from a pinned openai/codex checkout — never edit it by hand: + +```bash +git clone --filter=blob:none --no-checkout --depth 1 \ + https://github.com/openai/codex.git /tmp/codex-upstream +cd /tmp/codex-upstream && git sparse-checkout set codex-rs && git checkout +cd && npm run manifest -- --src /tmp/codex-upstream +``` + +`test/upstream-contract.test.ts` asserts this tool's request boundary against +the manifest, and re-extracts it live when `/tmp/codex-upstream` (or +`$CODEX_UPSTREAM_DIR`) exists. A weekly non-blocking +[`upstream-drift`](.github/workflows/upstream-drift.yml) workflow regenerates +the manifest from upstream HEAD and opens an issue on drift. + ## Roadmap ### v0.2 — Watch & Auto