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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions .github/workflows/upstream-drift.yml
Original file line number Diff line number Diff line change
@@ -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 <checkout>`), 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'],
});
}
80 changes: 79 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -152,6 +158,60 @@ own copies under `~/.codex-switch/profiles/<alias>/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.<account>.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 |
Expand All @@ -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 <codex-reset checkout> && 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
Expand Down
10 changes: 10 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': [
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
49 changes: 46 additions & 3 deletions src/commands/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@

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,
consumeCredit,
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,
Expand Down Expand Up @@ -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);

Expand All @@ -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') {
Expand Down
Loading