Repository navigation
[Bug]: pool quota WHAM 401 bypasses stored-token refresh after plan changes #3019
Description
Client or integration
OpenCodex dashboard
Area
Authentication and account pool
Summary
A stored Codex pool account can retain a bearer whose JWT expiry is still in the future while the upstream service rejects that bearer after a subscription or account-plan transition. The account-list quota path calls WHAM with that bearer and immediately reports needsReauth: true on HTTP 401. It does not use the bounded stored-token recovery already used by the Responses/compact request path.
The expected behavior is one forced refresh for the rejected credential generation, followed by one WHAM replay with the rotated bearer. OpenCodex should require manual reauthentication only when the refresh grant or a structured WHAM response provides terminal revocation/expiry evidence. A bare second 401 should remain transient rather than being mislabeled as token expiry.
This is adjacent to, but not covered by, #2887/#2889: those changes recover Responses/compact requests. It is also distinct from #3003, which throttles repeated failed quota-prime calls without refreshing a WHAM-rejected pool bearer.
Reproduction
- Add a Codex-login account to the stored account pool and confirm that its refresh grant works.
- Change that account's subscription or plan in a way that causes the upstream service to rotate or reject the current access bearer while the stored JWT
expremains in the future. - Refresh the Codex authentication dashboard, or request
GET /api/codex-auth/accounts?refresh=1. - Observe that the WHAM quota request returns HTTP 401 and the account DTO reports
needsReauth: true/ token expired without attempting the stored-token forced refresh. - Reauthenticate the same account manually and repeat the account-list request. The account works again and its current plan/quota can be read, showing that the previous classification was not proof that the refresh grant had expired.
Version
2.36.0; the missing recovery path was also confirmed on dev before the proposed fix.
Operating system
Windows 11 24H2
Provider and model
OpenAI Codex login; model-independent account quota lookup
Logs or error output
GET /api/codex-auth/accounts?refresh=1
WHAM quota lookup: HTTP 401
account result: needsReauth=true, quotaError=token_expiredScreenshots and supporting files
No attachment is required; the regression is covered with redacted in-process HTTP fixtures.
Redacted configuration
{
"codexAuth": {
"mode": "pool"
}
}Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
lidge-jun commentedon Aug 30, 2026
리뷰 · 우선순위 70 / 80
이 이슈가 말하는 구멍은 지금
devHEAD870a2adb6에도 그대로 있다. 패키지 버전은2.37.0이다. 작성자가 본 설치본은v2.36.0이고, 그 사이 올라간 것은 버전 줄과 릴리스 워크플로(#3013)다. 저장 Codex 풀의 계정 목록 쿼터 길은 그대로다. 대시보드가GET /api/codex-auth/accounts?refresh=1을 부르면, 풀 계정마다 WHAM(https://chatgpt.com/backend-api/wham/usage)을 한 번 친다. 그 응답이 HTTP 401이면 계정 DTO는 바로needsReauth: true가 된다. 리프레시 토큰을 한 번도 안 써 보고 그렇게 말한다.같은 HEAD에는 이미 #2889 가 있다. 그 수리는 Responses/compact 요청 길이다. 액세스 토큰 만료 시각이 아직 남았는데 업스트림이 401을 주면, 일반
pool은forceRefreshCodexPoolToken으로 강제 refresh 한 번과 같은 계정 replay 한 번을 탄다. #2897 은 그 예산을 계정으로 가둔다. #2848 은 네이티브 메인auth.json이 만료됐을 때 파일을 고친다. 그런데 대시보드가 그리는 풀 쿼터 조회는 그 기차를 타지 않는다. 구독이나 플랜이 바뀌면 업스트림은 아직exp가 남은 액세스를 거절할 수 있다. 그때 화면은 다시 로그인하라고 말한다. 리프레시 토큰은 살아 있는데도 그렇다. 이슈가 적은 재현, 손으로 다시 로그인한 뒤에 같은 계정이 다시 살아난다는 관찰이 바로 그 뜻이다.코드는 이렇게 갈라진다.
src/codex/account-store.ts의getValidCodexToken은 만료 시각이 아직 남으면 저장 bearer를 그대로 돌려준다. 주석이 이미 적는다. 그 지름길이 시간상 유효한 토큰의 401을 회복 불가로 만든다고. 그래서 강제 호출자만 그 지름길을 건너뛴다.forceRefreshCodexPoolToken이 그 API 다. 그런데src/codex/auth-api.ts의fetchFreshPoolAccountQuota는getValidCodexToken만 부른 뒤 WHAM을 한 번 치고, 실패면needsReauth: resp.status === 401이다. 본문 코드도 안 읽고, 강제 refresh도 안 탄다. 메인 계정 WHAM은isTerminalMainAuthResponse가 본문의 단말 코드(invalid_workspace_selected,invalid_refresh_token)와 JWT 생존을 본다. 맨몸 401만으로 바로 죽이지 않는다. 풀 목록만 그 보호가 없다.TokenRefreshError를 잡는 칸은 이미needsReauth: true다. 그건 리프레시 grant 가 진짜로 죽었을 때의 길이라 맞다. 구멍이 아닌 쪽이다.이 이슈는 #2887 / #2889 의 중복이 아니다. 그 두 개는 요청 길이다. #3003 과도 다르다. 그 PR은 실패한 quota-prime 을 줄이는 쪽이고, 거절된 bearer를 돌리지는 않는다. types.ts/config.ts 분할과도 안 겹친다. 미리보기 배포는 계획에 없다. 이미 초안 PR #3020 이
Closes #3019로 열려 있다. 그 PR은 이 목록 WHAM에 같은 세대 예산의 강제 refresh와 replay를 붙인다. 인증 표면이라 위생은unsponsored_surface로 막혀 있고, 지금 상태는 draft 다.라인 src/codex/auth-api.ts 981 -
needsReauth: resp.status === 401한 줄이다. WHAM 본문의 단말 코드를 안 읽고, 강제 refresh도 안 탄다. 시간상 유효한 bearer의 맨몸 401이 바로 다시 로그인이다.라인 src/codex/account-store.ts 629 -
getValidCodexToken이 만료 시각이 남은 저장 bearer를 그대로 돌려준다. 쿼터 길이 그 지름길을 그대로 먹는다. 주석이 이미 이 조합을 회복 불가로 부른다.경로 src/codex/account-store.ts forceRefreshCodexPoolToken - #2889 가 요청 길에 붙여 둔 API 다. 목록 WHAM은 이 함수를 부르지 않는다. 그래서 같은 저장 계정이 채팅은 회복되고 대시보드는 다시 로그인을 그린다.
라인 src/codex/auth-api.ts 617 - 메인 WHAM은
isTerminalMainAuthResponse가 맨몸 401을 단말로 안 본다. JWTexp를 못 읽거나 본문에 단말 코드가 있을 때만 죽인다. 풀 목록은 그 함수를 안 탄다.라인 src/codex/auth-api.ts 277 -
poolAccountDto가quotaResult.needsReauth를 카드 전체에 OR 한다. 쿼터 조회 한 번의 401이 별칭, 우선순위, 자격 증명이 남아 있는데도 다시 로그인으로 보인다.라인 src/codex/auth-api.ts 1447 -
GET /api/codex-auth/accounts의?refresh=1은fetchPoolAccountQuota(..., true)로 캐시만 건너뛴다. 그 generation의 강제 refresh 예산은 열리지 않는다. 새로고침을 눌러도 같은 401이면 같은needsReauth다.메인테이너의 판단이 필요한 지점
maintainer-sponsored를 달고 위생과 draft 게이트를 푼 뒤 이 이슈를 같이 닫을지너의 추천
열어 둔다. 중복으로 닫지 마라. #2887 / #2889 는 요청 길이고, #3003 은 실패한 prime 을 줄이는 쪽이다. 라벨을 바꾸지 마라. 고치는 자리는
fetchFreshPoolAccountQuota다. 이미 있는forceRefreshCodexPoolToken을 붙이면 된다. 새 토큰 교환기를 만들지 마라. 초안 PR #3020 이 그 방향이다.maintainer-sponsored를 달아 위생과 draft 를 푼 뒤, CI가 초록이면dev에 합쳐라. 합치면Closes #3019로 이 이슈가 같이 닫힌다. 원래 #2889 브랜치를 rebase 하라고 돌려보내지 마라.이 댓글은 grok-bot이 작성했습니다
docs(devlog): scan the priority-70 backlog for round 2 and lock the r…
docs(devlog): record what 12 review rounds changed in the #3019 unit
fix(codex): refresh a stored token before quarantining it on a WHAM 4…
lidge-jun commentedon Sep 1, 2026
Fixed on
devin a73a4c9 (PR #3119).The account list now refreshes the stored token once and replays the WHAM request once
before deciding anything.
needsReauthrequires real evidence: either a structuredterminal code in the response body — the same
MAIN_TERMINAL_AUTH_CODESallowlist andbounded parser the main account already uses, since it is the same endpoint — or a refresh
that failed with
reasonrevokedorexpired. A bare 401 is transient, which is whatyour plan change was producing.
The bounded half took most of the work, because "refresh on 401" without a budget is a
self-inflicted credential-stuffing loop against an upstream that is already saying no.
There is now one recovery attempt per credential lineage, and three things make that hold:
Generation alone cannot tell an old claimant from a later retry on the same generation,
so a late completion would spend a budget it no longer owns.
G → G+1before the claimant can settle, so aG+1claim in that window would leavethe settlement landing on nothing and
G+1unspent.that caller awaits while the shared refresh keeps running and commits; without a
flight-owned completion the claim would expire and the already-refreshed credential
would get a second refresh.
forceRefreshCodexPoolTokenalso reports provenance now. ItsselfRefreshedboolean wasambiguous in a way that mattered here:
falsemeant both "somebody else replaced thecredential" and "I joined an in-flight refresh of the same grant". The first is a new
credential entitled to its own attempt; the second is this one using its attempt. Charging
the wrong one either denies a fresh credential its recovery or hands a used lineage a
second refresh.
Terminal evidence is recorded durably and scoped to the credential it condemns, so it
survives the budget being spent without following a replacement after you re-authenticate.
On the security side, per
MAINTAINERS.md: at most one refresh-and-replay per lineage, andneither bearer is logged or serialized. Both are asserted at runtime rather than by static
scan — the tests capture the console, read the real debug buffer the dashboard exposes, and
serialize the account list, checking the rejected token, the rotated token and both refresh
tokens appear in none of them.
This carries the core sequence of #3020 with credit; that branch was not rebased.
Design record and the round-by-round history:
devlog/_plan/260831_prio70_train_round2/060_wp6_wham_401_refresh.mdand061_wp6_outcome.md.github-actions commentedon Sep 1, 2026
Automated translation bookkeeping — detected language: English.
docs(devlog): scan the priority-70 backlog for round 2 and lock the r…
fix(codex): refresh a stored token before quarantining it on a WHAM 4…
docs(devlog): scan the priority-70 backlog for round 2 and lock the r…
fix(codex): refresh a stored token before quarantining it on a WHAM 4…