Skip to content

[Bug]: pool quota WHAM 401 bypasses stored-token refresh after plan changes #3019

Closed

Description

@luvs01

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

  1. Add a Codex-login account to the stored account pool and confirm that its refresh grant works.
  2. 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 exp remains in the future.
  3. Refresh the Codex authentication dashboard, or request GET /api/codex-auth/accounts?refresh=1.
  4. 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.
  5. 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_expired

Screenshots 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

  1. added
    bugSomething isn't working
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    on Aug 30, 2026
  2. lidge-jun commented on Aug 30, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 70 / 80

    이 이슈가 말하는 구멍은 지금 dev HEAD 870a2adb6 에도 그대로 있다. 패키지 버전은 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을 단말로 안 본다. JWT exp 를 못 읽거나 본문에 단말 코드가 있을 때만 죽인다. 풀 목록은 그 함수를 안 탄다.
    라인 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 다.

    메인테이너의 판단이 필요한 지점

    • 이미 열린 초안 PR fix(codex): recover pool quota auth after WHAM 401 #3020 에 maintainer-sponsored 를 달고 위생과 draft 게이트를 푼 뒤 이 이슈를 같이 닫을지
    • 풀 WHAM 401의 액세스 생존을 메인처럼 JWT 디코드로 맞출지, 맨몸 401이면 일단 refresh를 태울지
    • 단말 기억을 GUI 강제 새로고침이 뚫지 못하게 둘지. 죽은 grant를 두드리지 않으려면 맞지만, 운영자는 다시 새로고침이 안 먹힌다고 느낀다
    • fix(codex): throttle repeated failed pool quota primes #3003 의 quota-prime 스로틀과 이 회복을 한 PR에 넣을지, 지금처럼 따로 둘지

    너의 추천
    열어 둔다. 중복으로 닫지 마라. #2887 / #2889 는 요청 길이고, #3003 은 실패한 prime 을 줄이는 쪽이다. 라벨을 바꾸지 마라. 고치는 자리는 fetchFreshPoolAccountQuota 다. 이미 있는 forceRefreshCodexPoolToken 을 붙이면 된다. 새 토큰 교환기를 만들지 마라. 초안 PR #3020 이 그 방향이다. maintainer-sponsored 를 달아 위생과 draft 를 푼 뒤, CI가 초록이면 dev 에 합쳐라. 합치면 Closes #3019 로 이 이슈가 같이 닫힌다. 원래 #2889 브랜치를 rebase 하라고 돌려보내지 마라.

    이 댓글은 grok-bot이 작성했습니다

  3. lidge-jun commented on Sep 1, 2026

    @lidge-jun
    Owner

    Fixed on dev in a73a4c9 (PR #3119).

    The account list now refreshes the stored token once and replays the WHAM request once
    before deciding anything. needsReauth requires real evidence: either a structured
    terminal code in the response body — the same MAIN_TERMINAL_AUTH_CODES allowlist and
    bounded parser the main account already uses, since it is the same endpoint — or a refresh
    that failed with reason revoked or expired. A bare 401 is transient, which is what
    your 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:

    • The claim carries an opaque id rather than being keyed on the credential generation.
      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.
    • A live claim blocks every lineage for the account. The refresh it fences commits
      G → G+1 before the claimant can settle, so a G+1 claim in that window would leave
      the settlement landing on nothing and G+1 unspent.
    • Settlement rides the refresh, not the caller. Cancelling a dashboard poll rejects what
      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.

    forceRefreshCodexPoolToken also reports provenance now. Its selfRefreshed boolean was
    ambiguous in a way that mattered here: false meant both "somebody else replaced the
    credential" 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, and
    neither 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.md and 061_wp6_outcome.md.

  4. github-actions commented on Sep 1, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions