|
| 1 | +# Codex auth warmup + freshness refresh follow-up — PLAN |
| 2 | + |
| 3 | +Date: 2026-07-05 |
| 4 | +Status: IMPLEMENTED |
| 5 | +Work class: C4-lite (auth/session validation, refresh-token lifecycle, multi-account pool safety) |
| 6 | +Branch context: `codex/gpt-56-sol-terra-luna-rollout` |
| 7 | + |
| 8 | +## Request |
| 9 | + |
| 10 | +After the previous token-refresh patch, most Codex pool accounts still became unusable except one. |
| 11 | +The working hypothesis from the live incident is: |
| 12 | + |
| 13 | +> After OAuth login/import, opencodex records the token but does not actually send a small Codex |
| 14 | +> `/responses` request. Accounts that are logged in and then left idle never get their Codex backend |
| 15 | +> session validated/warmed, while the one account that receives real traffic survives. |
| 16 | +
|
| 17 | +This plan is a follow-up to `devlog/_fin/260703_oauth-multi-account-refresh-and-tos/00_plan.md`. |
| 18 | +That patch added a Token Guardian, but its actual Codex pool sweep is still access-token-expiry |
| 19 | +based, not Codex `last_refresh` / real backend-use based. |
| 20 | + |
| 21 | +## Current evidence |
| 22 | + |
| 23 | +### opencodex behavior |
| 24 | + |
| 25 | +- Login completion checks `https://chatgpt.com/backend-api/wham/usage`, not Codex `/responses`: |
| 26 | + `src/codex-auth-api.ts:557`. |
| 27 | +- Login then immediately persists the pool account credential: |
| 28 | + `src/codex-auth-api.ts:578`. |
| 29 | +- Pool quota refresh also checks `/wham/usage`, so it can report quota without proving the |
| 30 | + `/backend-api/codex/responses` path works: `src/codex-auth-api.ts:238`. |
| 31 | +- `getValidCodexToken()` returns the existing token whenever `expiresAt` is still outside the |
| 32 | + local skew window: `src/codex-account-store.ts:285`. |
| 33 | +- Token Guardian skips Codex pool accounts whose `expiresAt` is beyond its horizon: |
| 34 | + `src/oauth/token-guardian.ts:143`. |
| 35 | +- The ChatGPT forward adapter's actual Codex request path is `${baseUrl}/responses`; for ChatGPT |
| 36 | + that means `https://chatgpt.com/backend-api/codex/responses`: |
| 37 | + `src/adapters/openai-responses.ts:89`. |
| 38 | + |
| 39 | +### External/reference behavior |
| 40 | + |
| 41 | +- OpenAI Codex CI/CD auth docs describe Codex-managed auth as refreshing based on `last_refresh` |
| 42 | + age, writing the refreshed auth cache back, and using a real Codex run such as a one-word OK |
| 43 | + reply to keep auth fresh: |
| 44 | + <https://developers.openai.com/codex/auth/ci-cd-auth>. |
| 45 | +- `Soju06/codex-lb` has a quota warmup path that defaults to `gpt-5.4-mini` and sends an actual |
| 46 | + streamed `/codex/responses` probe. Local inspection points: |
| 47 | + `/tmp/codex-lb-inspect/app/modules/quota_planner/warmup.py:79` and |
| 48 | + `/tmp/codex-lb-inspect/app/modules/quota_planner/warmup.py:395`. |
| 49 | + |
| 50 | +## Root-cause statement |
| 51 | + |
| 52 | +The previous patch protected the local refresh code path from simple access-token expiry, but it |
| 53 | +did not guarantee that each stored Codex pool account has recently exercised the Codex backend. |
| 54 | +Because the store has no durable `lastRefresh` / `lastValidated` style timestamp, an account with |
| 55 | +a far-future `expiresAt` is considered valid indefinitely by opencodex even if upstream Codex has |
| 56 | +silently invalidated or not initialized the session. |
| 57 | + |
| 58 | +This makes `/wham/usage` a false positive: it proves the bearer token can query account metadata, |
| 59 | +but it does not prove a Codex Responses request can be created. |
| 60 | + |
| 61 | +## Patch goals |
| 62 | + |
| 63 | +1. After login/import, send a tiny `gpt-5.4-mini` Codex `/responses` request and require a valid |
| 64 | + response before the account is considered connected. |
| 65 | +2. Record durable freshness metadata for pool accounts so guardian decisions can be based on |
| 66 | + Codex-session validation age, not only access-token expiration. |
| 67 | +3. Keep all traffic explicit, low-volume, and user-configurable because synthetic traffic has |
| 68 | + account-safety/ToS implications. |
| 69 | +4. Reuse existing refresh locking and generation-CAS machinery; do not invent a second refresh |
| 70 | + coordinator. |
| 71 | + |
| 72 | +## Non-goals |
| 73 | + |
| 74 | +- No blanket proactive traffic for all providers. |
| 75 | +- No Anthropic/OAuth behavior change. |
| 76 | +- No rate-limit bypass automation. |
| 77 | +- No silent loop that keeps many idle accounts hot by default. |
| 78 | +- No changes to the Sol/Terra/Luna rollout metadata branch content except this planning doc unless |
| 79 | + implementation is explicitly requested on this branch. |
| 80 | + |
| 81 | +## Proposed implementation phases |
| 82 | + |
| 83 | +### Phase 1 — Codex Responses warmup helper |
| 84 | + |
| 85 | +Add a small helper, probably `src/codex-warmup.ts` or a tightly scoped function in |
| 86 | +`src/codex-auth-api.ts`. |
| 87 | + |
| 88 | +Behavior: |
| 89 | + |
| 90 | +- POST to `https://chatgpt.com/backend-api/codex/responses`. |
| 91 | +- Headers: |
| 92 | + - `Authorization: Bearer <accessToken>` |
| 93 | + - `ChatGPT-Account-Id: <chatgptAccountId>` |
| 94 | + - `Content-Type: application/json` |
| 95 | +- Body: |
| 96 | + - `model: "gpt-5.4-mini"` |
| 97 | + - `instructions: "Reply with OK."` |
| 98 | + - `input: "hi"` |
| 99 | + - `stream: true` |
| 100 | + - `store: false` |
| 101 | + - no `max_output_tokens`; existing ChatGPT Codex sidecars note that this backend rejects it |
| 102 | +- Drain SSE until a terminal success event (`response.completed`). |
| 103 | +- Fail closed on: |
| 104 | + - HTTP 401/403/429/5xx before streaming; |
| 105 | + - streamed `response.failed`, `response.incomplete`, or `error` events; |
| 106 | + - EOF before a success terminal; |
| 107 | + - malformed terminal JSON. |
| 108 | + |
| 109 | +Important distinction: codex-lb uses warmup probes for quota planning. opencodex should use this |
| 110 | +first as auth/session validation, not as hidden quota shaping. |
| 111 | + |
| 112 | +### Phase 2 — Login/import gate |
| 113 | + |
| 114 | +Patch successful OAuth login flow: |
| 115 | + |
| 116 | +- Keep the existing `/wham/usage` fetch for email/plan/quota metadata. |
| 117 | +- Before marking the flow successful, run the warmup helper using the just-issued token. |
| 118 | +- If warmup succeeds, save the credential and clear reauth state. |
| 119 | +- If warmup fails with auth/session status, do not mark the account connected; surface a clear |
| 120 | + login-state error. |
| 121 | +- If warmup fails transiently, decide whether login should fail closed or save with |
| 122 | + `warmupStatus: "unknown"`; default recommendation is fail closed for auth/session errors and |
| 123 | + retryable error for transport failures. |
| 124 | + |
| 125 | +Patch manual/import flow too, otherwise imported accounts can reproduce the same idle-token path. |
| 126 | + |
| 127 | +Candidate files: |
| 128 | + |
| 129 | +- `src/codex-auth-api.ts` |
| 130 | +- `src/codex-account-store.ts` |
| 131 | +- `tests/codex-auth-api.test.ts` or adjacent auth-flow tests |
| 132 | + |
| 133 | +### Phase 3 — Durable freshness metadata |
| 134 | + |
| 135 | +Extend `CodexAccountCredentialRecord` to include: |
| 136 | + |
| 137 | +- `lastCodexWarmupAt?: number` |
| 138 | +- `lastCodexWarmupStatus?: "ok" | "failed" | "unknown"` |
| 139 | +- `lastCodexWarmupError?: string` |
| 140 | + |
| 141 | +Alternative name if we want to match Codex docs more closely: |
| 142 | + |
| 143 | +- `lastRefreshAt?: number` for token refresh |
| 144 | +- `lastCodexValidatedAt?: number` for actual `/responses` validation |
| 145 | + |
| 146 | +Recommendation: use `lastCodexValidatedAt`, because it is semantically different from OAuth |
| 147 | +refresh. A successful `/responses` warmup may not rotate tokens. |
| 148 | + |
| 149 | +Backwards compatibility: |
| 150 | + |
| 151 | +- Existing records without these fields remain readable. |
| 152 | +- Legacy `loadCodexAccountStore()` continues returning only credentials. |
| 153 | +- Do not include tokens in logs or persisted error strings. |
| 154 | +- `saveCodexAccountCredential()` and `saveCodexAccountCredentialIfGeneration()` must preserve |
| 155 | + validation metadata across ordinary saves/token refreshes. Otherwise a later refresh erases the |
| 156 | + freshness signal that guardian depends on. |
| 157 | +- Add a dedicated metadata helper, e.g. `markCodexAccountValidated(id, now)`, so warmup can update |
| 158 | + validation fields without rewriting credential material. |
| 159 | + |
| 160 | +### Phase 4 — Guardian age policy |
| 161 | + |
| 162 | +Add an optional Codex pool freshness sweep: |
| 163 | + |
| 164 | +- Keep `tokenGuardian.enabled` and `refreshPolicy` gates. |
| 165 | +- Add config: |
| 166 | + - `tokenGuardian.codexWarmupEnabled?: boolean` |
| 167 | + - `tokenGuardian.codexWarmupMaxAgeSeconds?: number` |
| 168 | + - `tokenGuardian.codexWarmupModel?: string` default `gpt-5.4-mini` |
| 169 | +- For each active pool account: |
| 170 | + - if access token is near expiry, reuse existing `getValidCodexToken()`; |
| 171 | + - if `lastCodexValidatedAt` is older than max age, call warmup helper; |
| 172 | + - use the existing concurrency/backoff controls. |
| 173 | + |
| 174 | +Default recommendation: |
| 175 | + |
| 176 | +- `codexWarmupEnabled` defaults OFF. |
| 177 | +- Login-time warmup defaults ON because it is part of connection verification, not background |
| 178 | + synthetic usage. |
| 179 | + |
| 180 | +### Phase 5 — Request-time recovery (deferred from this implementation pass) |
| 181 | + |
| 182 | +Add or verify one retry path for Codex pool requests: |
| 183 | + |
| 184 | +- If `/responses` returns 401/403 for a selected pool account, mark the account as needing reauth |
| 185 | + or refresh once via `getValidCodexToken()` if access expiry says it is stale. |
| 186 | +- Do not loop across accounts indefinitely; respect existing routing and account-safety policy. |
| 187 | +- Record the failure in request logs without token material. |
| 188 | + |
| 189 | +This phase is separate because request-path retries can alter routing semantics. It touches |
| 190 | +`src/codex-auth-context.ts`, `src/server.ts`, `src/codex-routing.ts`, and broader request-path |
| 191 | +tests, so this implementation pass should not claim Phase 5 done. |
| 192 | + |
| 193 | +Optional follow-up: update `lastCodexValidatedAt` on any successful real pool-backed Codex |
| 194 | +`/responses` request, not only synthetic warmup. That would reduce unnecessary optional guardian |
| 195 | +warmups for accounts that are already receiving traffic. |
| 196 | + |
| 197 | +## Test plan |
| 198 | + |
| 199 | +Unit tests: |
| 200 | + |
| 201 | +- Login success sends `/wham/usage`, then sends `gpt-5.4-mini` warmup, then saves credential. |
| 202 | +- Login warmup 401/403 does not save as connected and reports reauth/login error. |
| 203 | +- Login warmup transient failure returns retryable error without leaking token data. |
| 204 | +- Manual import path also warmups before final connected state. |
| 205 | +- Store parser accepts records with and without `lastCodexValidatedAt`. |
| 206 | +- Store writes preserve `lastCodexValidatedAt` across token refresh / generation-CAS saves. |
| 207 | +- Guardian skips fresh `lastCodexValidatedAt` records even if many accounts exist. |
| 208 | +- Guardian warmups stale records only when global switch + Codex warmup switch allow it. |
| 209 | +- Guardian preserves existing access-token refresh behavior. |
| 210 | +- Warmup SSE parser treats streamed `response.failed`, `response.incomplete`, `error`, malformed |
| 211 | + terminal JSON, and EOF-before-success as failures. |
| 212 | +- Warmup failure messages do not persist access tokens, refresh tokens, account IDs, or raw upstream |
| 213 | + descriptions. |
| 214 | + |
| 215 | +Integration/manual checks: |
| 216 | + |
| 217 | +- Add one account, confirm request log shows one tiny `gpt-5.4-mini` Codex Responses warmup. |
| 218 | +- Leave account idle, run guardian one-shot, confirm no warmup if freshness is inside max age. |
| 219 | +- Force stale freshness metadata, run guardian one-shot, confirm exactly one warmup per eligible |
| 220 | + account under concurrency limit. |
| 221 | +- Confirm existing provider auth flows are untouched. |
| 222 | + |
| 223 | +Commands: |
| 224 | + |
| 225 | +```bash |
| 226 | +bun test tests/codex-auth-api.test.ts tests/codex-account-store.test.ts tests/token-guardian.test.ts |
| 227 | +bun test tests/*.test.ts |
| 228 | +npx tsc --noEmit |
| 229 | +``` |
| 230 | + |
| 231 | +## Risk notes |
| 232 | + |
| 233 | +- Synthetic Codex traffic is account-sensitive. Keep login-time verification small and obvious; |
| 234 | + keep background warmup opt-in. |
| 235 | +- Refresh tokens can be single-use/rotating. All paths must continue through the existing |
| 236 | + generation-CAS and file-lock logic in `src/codex-account-store.ts`. |
| 237 | +- `/wham/usage` and `/codex/responses` have different semantics. Never treat one as proof of the |
| 238 | + other. |
| 239 | +- SSE parsing must drain/abort cleanly so login does not hang on a streaming response. |
| 240 | +- Do not write token values to logs, devlog, tests, snapshots, request logs, or error messages. |
| 241 | + |
| 242 | +## Acceptance criteria |
| 243 | + |
| 244 | +- A newly connected Codex pool account is not shown as connected unless a real Codex Responses |
| 245 | + warmup succeeds or the UI explicitly labels it as warmup-unknown. |
| 246 | +- Stored account records contain durable Codex validation freshness after successful warmup. |
| 247 | +- Guardian can warm stale Codex pool accounts by validation age when explicitly enabled. |
| 248 | +- No default background synthetic traffic is introduced. |
| 249 | +- Existing Token Guardian tests still pass. |
| 250 | +- The user can diagnose the difference between metadata auth (`/wham/usage`) and usable Codex |
| 251 | + session (`/codex/responses`) from logs/UI without reading source. |
| 252 | + |
| 253 | +## Implementation record |
| 254 | + |
| 255 | +Implemented on 2026-07-05. |
| 256 | + |
| 257 | +Files changed: |
| 258 | + |
| 259 | +- NEW `src/codex-warmup.ts`: minimal `gpt-5.4-mini` warmup request to |
| 260 | + `https://chatgpt.com/backend-api/codex/responses`; drains streamed SSE; succeeds only on |
| 261 | + `response.completed`; fails on HTTP rejection, `response.failed`, `response.incomplete`, |
| 262 | + `type:"error"`, malformed SSE JSON, or EOF before success. |
| 263 | +- `src/codex-auth-api.ts`: manual import and OAuth pool login now run warmup before persisting the |
| 264 | + account as connected. On success they record validation metadata. On failure they return/report a |
| 265 | + redacted `codex_warmup_failed` reason and do not add the account. |
| 266 | +- `src/codex-account-store.ts` / `src/types.ts`: account records now support |
| 267 | + `lastCodexValidatedAt`, `lastCodexValidationStatus`, and `lastCodexValidationError`; ordinary |
| 268 | + credential saves and generation-CAS refresh saves preserve this metadata. |
| 269 | +- `src/oauth/token-guardian.ts`: optional background validation-age warmup via |
| 270 | + `tokenGuardian.codexWarmupEnabled`, `codexWarmupMaxAgeSeconds`, and `codexWarmupModel`. This is |
| 271 | + opt-in and remains gated by global `tokenGuardian.enabled` plus ChatGPT `refreshPolicy: |
| 272 | + "proactive"`. |
| 273 | +- Tests added/updated for warmup success/failure parsing, manual-import warmup gate, metadata |
| 274 | + preservation, and optional guardian warmup. |
| 275 | + |
| 276 | +Verification: |
| 277 | + |
| 278 | +```bash |
| 279 | +bun test tests/codex-warmup.test.ts tests/codex-auth-api.test.ts tests/codex-account-store.test.ts tests/token-guardian.test.ts |
| 280 | +# 85 pass / 0 fail |
| 281 | + |
| 282 | +npx tsc --noEmit |
| 283 | +# exit 0 |
| 284 | + |
| 285 | +bun test tests/*.test.ts |
| 286 | +# 1392 pass / 0 fail |
| 287 | +``` |
| 288 | + |
| 289 | +C-gate independent review found no blocking issues after implementation. Residual known gap: |
| 290 | +OAuth browser-login completion is still covered mostly by source-shape assertions plus inspection, |
| 291 | +not a full behavioral async flow test; manual import has direct behavioral coverage. |
0 commit comments