Skip to content

Commit 7b34878

Browse files
committed
fix(auth): verify Codex pool accounts with warmup
1 parent 6911be6 commit 7b34878

10 files changed

Lines changed: 789 additions & 10 deletions

File tree

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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.

src/codex/account-store.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ function isCredentialRecord(value: unknown): value is CodexAccountCredentialReco
4444
&& (value.credential === undefined || isCredential(value.credential))
4545
&& (value.refreshGrantFingerprint === undefined || typeof value.refreshGrantFingerprint === "string")
4646
&& (value.deletedAt === undefined || typeof value.deletedAt === "number")
47-
&& (value.replacedAt === undefined || typeof value.replacedAt === "number");
47+
&& (value.replacedAt === undefined || typeof value.replacedAt === "number")
48+
&& (value.lastCodexValidatedAt === undefined || typeof value.lastCodexValidatedAt === "number")
49+
&& (value.lastCodexValidationStatus === undefined || value.lastCodexValidationStatus === "ok" || value.lastCodexValidationStatus === "failed")
50+
&& (value.lastCodexValidationError === undefined || typeof value.lastCodexValidationError === "string");
4851
}
4952

5053
export function refreshGrantFingerprintForToken(refreshToken: string): string {
@@ -98,6 +101,17 @@ function persist(store: CodexAccountStore): void {
98101
atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
99102
}
100103

104+
function preservedValidationMetadata(record: CodexAccountCredentialRecord | undefined): Pick<
105+
CodexAccountCredentialRecord,
106+
"lastCodexValidatedAt" | "lastCodexValidationStatus" | "lastCodexValidationError"
107+
> {
108+
return {
109+
...(record?.lastCodexValidatedAt !== undefined ? { lastCodexValidatedAt: record.lastCodexValidatedAt } : {}),
110+
...(record?.lastCodexValidationStatus !== undefined ? { lastCodexValidationStatus: record.lastCodexValidationStatus } : {}),
111+
...(record?.lastCodexValidationError !== undefined ? { lastCodexValidationError: record.lastCodexValidationError } : {}),
112+
};
113+
}
114+
101115
export function getCodexAccountCredential(id: string): CodexAccountCredentials | null {
102116
const record = readCodexAccountRecord(id);
103117
if (!record || record.deletedAt != null) return null;
@@ -115,6 +129,32 @@ export function saveCodexAccountCredential(id: string, cred: CodexAccountCredent
115129
generation: (current?.generation ?? 0) + 1,
116130
refreshGrantFingerprint,
117131
replacedAt: current ? Date.now() : undefined,
132+
...preservedValidationMetadata(current),
133+
};
134+
persist(store);
135+
}
136+
137+
export function markCodexAccountValidated(id: string, atMs: number = Date.now()): void {
138+
const store = loadCodexAccountRecordStore();
139+
const current = store[id];
140+
if (!current || current.deletedAt != null || !current.credential) return;
141+
store[id] = {
142+
...current,
143+
lastCodexValidatedAt: atMs,
144+
lastCodexValidationStatus: "ok",
145+
lastCodexValidationError: undefined,
146+
};
147+
persist(store);
148+
}
149+
150+
export function markCodexAccountValidationFailed(id: string, reason: string): void {
151+
const store = loadCodexAccountRecordStore();
152+
const current = store[id];
153+
if (!current || current.deletedAt != null || !current.credential) return;
154+
store[id] = {
155+
...current,
156+
lastCodexValidationStatus: "failed",
157+
lastCodexValidationError: reason,
118158
};
119159
persist(store);
120160
}
@@ -154,6 +194,7 @@ export function saveCodexAccountCredentialIfGeneration(
154194
generation: generation + 1,
155195
refreshGrantFingerprint,
156196
replacedAt: current.replacedAt,
197+
...preservedValidationMetadata(current),
157198
};
158199
persist(store);
159200
return true;

0 commit comments

Comments
 (0)