Skip to content

[Bug]: revoked-but-time-valid pool credential keeps lastCodexValidationStatus: "ok" — dashboard reports the account healthy indefinitely #4120

Closed

Description

@nordz0r

Client or integration

OpenCodex dashboard

Area

Authentication and account pool

Summary

A stored Codex pool credential whose OAuth grant was revoked upstream keeps lastCodexValidationStatus: "ok" in codex-accounts.json and is presented as healthy for days. In the default configuration nothing ever re-checks it, and the one code path that would record a failure explicitly excludes the terminal failure class.

Reproduction

Steps to reproduce

  1. Register a Codex pool account; note lastCodexValidationStatus: "ok".
  2. Invalidate its grant upstream (sign the session out, or exchange the same refresh grant from another client so the pair rotates), keeping expiresAt in the future.
  3. Leave codexWarmupEnabled unset and let guardian sweeps run.
  4. codex-accounts.json still says ok and the dashboard still shows the account as healthy, while GET /backend-api/wham/usage with the stored bearer returns token_revoked.

Observed

15 pool accounts registered 2026-09-07; two days later the store records still read (redacted):

alias                  generation  lastCodexValidatedAt  lastCodexValidationStatus  replacedAt
openai-sad+07@<redact>          1  2026-09-07 17:16      ok                         —
openai-sad+08@<redact>          1  2026-09-07 17:24      ok                         —

while the stored access token is rejected:

GET https://chatgpt.com/backend-api/wham/usage
401 {"error":{"message":"Encountered invalidated oauth token for user","code":"token_revoked"}}

and so is the stored refresh grant — an external consumer exchanging it against https://auth.openai.com/oauth/token gets a bare 401. generation is still 1 and replacedAt is unset, so OpenCodex never rotated these credentials itself.

Why the record is never updated (2.48.0, src/oauth/token-guardian.ts, pool branch of guardianSweep)

const needsRefresh = cred.expiresAt <= nowMs + horizonMs;
const needsWarmup = opts.codexWarmupEnabled
  && (record.lastCodexValidatedAt === undefined
      || nowMs - record.lastCodexValidatedAt > opts.codexWarmupMaxAgeSeconds * 1000);
if (!needsRefresh && !needsWarmup) continue;
  • The credential is revoked but still time-valid (expiresAt ~8 days out), so needsRefresh is false;
  • codexWarmupEnabled is opt-in (g?.codexWarmupEnabled === true, line 85) and unset here, so needsWarmup is false;
  • The account is therefore skipped on every sweep and keeps its login-time verdict forever.

And when a sweep does run, the terminal case is excluded from the persisted record:

const permanent = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired");
if (needsWarmup && !(err instanceof TokenRefreshError)) {
  markCodexAccountValidationFailed(id, codexWarmupFailureReason(err));
}

A revoked/expired refresh grant is the strongest terminal evidence available, yet it is the one class that never reaches markCodexAccountValidationFailed. It only lands in the in-memory backoff map, which does not survive a restart and is not what the dashboard reads.

Expected

  1. A TokenRefreshError with reason revoked/expired should persist a terminal verdict on the account record (lastCodexValidationStatus: "failed" + reason), independent of needsWarmup.
  2. The dashboard should not present a days-old validation as current health — show lastCodexValidatedAt next to the status, or age the verdict out.
  3. Optionally revalidate stored pool credentials on a bounded schedule even with warmup disabled; a single WHAM usage probe is enough to detect token_revoked.

Impact

Accounts look healthy both in the pool and to anything reading codex-accounts.json, while every request with them 401s. In our deployment 6 of 12 accounts imported from this store into a LiteLLM subscription pool were dead on arrival, and it only became visible when the consumer forced an OAuth exchange and got 401 from the token endpoint.

Related

Distinct from #3019 (WHAM 401 in the account-list path not attempting a bounded refresh) and #2887 (time-valid token quarantined on the first 401) — both are about handling a live 401; this report is about the persisted health verdict never being updated for a terminally revoked grant.

Version

@bitkyc08/opencodex 2.48.0, pool mode, codexWarmupEnabled not set (default false)

Operating system

Linux / platform-independent

Provider and model

OpenAI / Codex (ChatGPT backend-api / WHAM)

Logs or error output

GET https://chatgpt.com/backend-api/wham/usage
401 {"error":{"message":"Encountered invalidated oauth token for user","code":"token_revoked"}}

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
    guiDashboard, tray, settings UI
    on Sep 9, 2026
  2. Ingwannu commented on Sep 9, 2026

    @Ingwannu
    Owner

    Confirmed the two source paths on current dev 57077ca326: time-valid pool credentials are skipped when warmup is disabled; when a refresh does fail with terminal TokenRefreshError (revoked/expired), the guardian records backoff but excludes that class from persisted validation failure.

    These are separate concerns. With background warmup disabled, the daemon cannot infer an upstream revocation it has not observed; enabling default probing would be a product/policy change. But once terminal refresh evidence is observed, leaving the stored validation verdict at ok is a concrete persistence defect. A narrow fix should persist that terminal verdict without requiring needsWarmup, preserve transient/busy/stale handling, and ensure an old in-flight failure cannot overwrite a reauthenticated/replaced credential. Tests need terminal revoked/expired, transient failure, and replacement-during-refresh controls. Timestamp/health wording remains a separate GUI review, not justification for adding default background probes. Keeping this open.

  3. lidge-jun commented on Sep 9, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 68 / 80

    이 이슈는 Codex 풀에 넣어 둔 계정의 OAuth grant가 업스트림에서 이미 폐기됐는데도, 저장된 lastCodexValidationStatus가 로그인 때의 "ok"로 남고 대시보드가 계속 건강하다고 보여 주는 버그를 말합니다. 재현 핵심은 (1) expiresAt은 아직 미래라 시간상으로는 유효해 보이고, (2) 기본값에서 codexWarmupEnabled가 꺼져 있어 스윕이 warmup도 안 하고, (3) 설령 refresh를 타서 TokenRefreshError(revoked/expired)가 나와도 그 판정이 디스크에 안 쓰이고 인메모리 backoff에만 쓰인다는 점입니다. 그래서 GET .../wham/usage는 token_revoked인데 UI·ocx status는 초록인 상태가 며칠씩 갑니다.

    현재 dev(5669b96b7)의 src/oauth/token-guardian.ts 풀 분기와 src/oauth/health.ts의 projectCodexAccountHealth를 보면 이슈 본문 분석과 일치합니다. permanent는 계산되지만 persist 분기는 needsWarmup && !(TokenRefreshError)에 묶여 있고, 프로젝터는 validation 메타를 읽지 않습니다. collectLocalCodexEntries가 프로젝터를 인라인 복사한 점도 CLI가 같은 거짓 건강을 반복할 수 있는 구조입니다. 계정 풀·GUI 라벨이 붙은 운영 버그라서, 2.50.0 라인에서 overflow/Spark 마감 다음으로도 우선순위가 높습니다.

    고치는 쪽은 이미 #4140(fix(codex): persist a terminal validation verdict for a revoked pool grant)이 열려 있습니다. 터미널 실패를 lastCodexValidationTerminal로 남기고 reauth_required로 투영하며, generation 펜스와 “성공 refresh/re-login이 마커를 지움”까지 포함합니다. 이슈에 적힌 기대 3(warmup 꺼도 주기적 재검증)은 #4140이 기본 켜진 probe라서 의도적으로 거절했고, lastCodexValidatedAt 대시보드 컬럼도 GUI 후속으로 미뤘습니다. 그 분리는 맞습니다. 다만 이슈 재현처럼 expiresAt이 멀어서 needsRefresh조차 false인 폐기 계정은, 스윕이 refresh를 안 타면 새 persist 경로에도 안 들어갑니다. #4140 머지 후에도 그 축이 남는지 확인이 필요합니다.

    src/oauth/token-guardian.ts (guardianSweep 풀 분기) - permanent를 backoff에만 사용. 이슈 재현의 쓰기 쪽 뿌리
    src/oauth/health.ts (projectCodexAccountHealth) - validation 메타 미참조 → 낡은 ok가 healthy로 나감
    collectLocalCodexEntries - 프로젝터 인라인 복사. CLI/doctor 거짓 건강 가능
    기대 3 (주기 재검증) - 기본 probe라 #4140 non-goal. 후속 opt-in으로 둘지 결정 필요
    #4140 - 수정 PR. 이 이슈는 PR 머지·잔여 축 확인 후 닫기

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

    너의 추천
    #4140 CI 그린 후 머지하고 이 이슈를 닫되, “refresh horizon 밖 폐기 계정”이 남으면 짧은 후속 이슈로 분리하라. 중복 PR을 새로 열지 말고 #4140을 정본으로 삼을 것. types/config 분할 캠페인과 무관하므로 close-don't-rebase 대상이 아니다.

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

  4. added 4 commits that reference this issue on Sep 9, 2026
    71a0c30
    5c74f76
    2156fbe
    aeb86cb
  5. lidge-jun commented on Sep 9, 2026

    @lidge-jun
    Owner

    Landed via #4140 at 71a0c30

  6. added 4 commits that reference this issue on Sep 17, 2026
    58f8221
    29033ac
    a71069c
    4a98f87
  7. added a commit that references this issue on Sep 25, 2026
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 workingguiDashboard, tray, settings UIlanded-via-maintainerOriginal PR closed after landing via a maintainer merge train

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions