Skip to content

fix(codex): persist a terminal validation verdict for a revoked pool grant - #4140

Merged
lidge-jun merged 2 commits into
devfrom
codex/codex-terminal-validation-4120
Sep 9, 2026
Merged

fix(codex): persist a terminal validation verdict for a revoked pool grant#4140
lidge-jun merged 2 commits into
devfrom
codex/codex-terminal-validation-4120

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Closes #4120.

A Codex pool credential whose OAuth grant was revoked upstream kept lastCodexValidationStatus: "ok" in codex-accounts.json and was presented as healthy indefinitely, while every request using it returned 401 token_revoked. The reporter found 6 of 12 accounts imported from such a store were dead on arrival.

guardianSweep's pool branch already classified the failure — it computed permanent for a revoked/expired TokenRefreshError — but spent it only on widening an in-memory backoff delay, which no health surface reads and no restart survives. The persisted-verdict branch beside it required needsWarmup (false in the default configuration, since codexWarmupEnabled defaults off) and additionally excluded every TokenRefreshError. So the one class of failure that proves a credential is dead was the one class that never reached the record.

Write side. A terminal TokenRefreshError now persists lastCodexValidationStatus: "failed" regardless of needsWarmup, plus a new lastCodexValidationTerminal marker that distinguishes a dead grant from a transient warmup failure. Background warmup stays opt-in — no default-on probe is introduced, so issue expectation 3 is deliberately not implemented.

The marker clears itself, which is what keeps one spurious invalid_grant from branding a live account dead forever: markCodexAccountValidated clears it explicitly, and every credential write drops it because those writers rebuild the record from preservedValidationMetadata, which deliberately omits it. A refresh that succeeds disproves "the grant was revoked".

Generation fence. markCodexAccountValidationFailed takes an optional expectedGeneration and returns whether it wrote. The sweep passes the generation it actually observed — record.generation before the refresh, token.generation once a refresh has committed. If another writer replaced the credential mid-flight the write is declined rather than applied: the failure cannot be attributed to a credential the sweep never observed, and declining is always safer than branding a freshly installed one dead.

Read side. projectCodexAccountHealth now reads that verdict and reports reauth_required/refresh_failed. That is the accurate projection rather than a convenient one: only a re-login recovers a revoked grant, and the existing union member already attaches CODEX_REAUTH_ACTION ("reauthenticate via the dashboard Codex account pool"). collectLocalCodexEntries kept an inlined copy of the projector instead of calling it, which is precisely how ocx status and ocx doctor would have gone on reporting the account healthy after the dashboard stopped; it is folded onto the shared projector so the two cannot drift again.

Two design notes a reviewer may want to challenge

Why a separate key instead of a third lastCodexValidationStatus value. isCredentialRecord admits only "ok" | "failed". A record carrying an unrecognized status fails that predicate, normalizeRecord then fails isCredential and returns undefined, and loadCodexAccountRecordStore silently omits the record — so an operator who wrote a "revoked" status and then rolled back to an earlier version would lose the whole account entry, credential included. An unknown extra key is spread through untouched instead.

Why there is no gui/ diff. The dashboard does not render the server's healthLabel; it recomputes the badge from the health object through gui/src/oauth-health-display.ts. Reusing reauth_required therefore already turns the account row amber, prints "Reauthentication required" and surfaces the action, with no GUI change. A new warning reason would have cost a GUI enum, nine i18n locales and a screenshot, for strictly worse copy. Showing lastCodexValidatedAt as a first-class dashboard column (the optional half of issue expectation 2) is left as a separate GUI change.

Verification

  • Local checks were NOT RUN, per maintainer instruction for this lane — no bun run test, no bun run test:changed, no bun run typecheck, no bun run build:gui, no bun run lint:gui, no bun install. The exact-head remote CI on this PR is the gate. This PR was also pushed with --no-verify under the same instruction.
  • Regression coverage was added to existing test files (a new file would additionally need entries in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json):
    • tests/codex-integration/token-guardian.test.ts — a revoked grant persists failed + terminal with warmup disabled; a transient (unknown) refresh failure leaves the recorded verdict untouched; a credential replaced mid-refresh is not branded by the previous credential's failure.
    • tests/codex-integration/codex-account-store.test.ts — the generation fence declines a stale write and accepts a current one; a terminal verdict is cleared by a completed validation, by a re-login, and by a successful CAS refresh, while a transient failure neither clears nor invents it.
    • tests/oauth/oauth-health.test.ts — a healthy record still projects healthy, a terminal record projects reauth_required, a non-terminal failure does not, and the CLI collector reports the terminal verdict with the Codex reauth action.
  • Design record: devlog/_plan/260909_codex_credential_health_chain/.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. (No user-facing configuration or CLI surface changed; the design record is in devlog/_plan/.)
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. This touches OAuth credential handling, so specifically: the persisted reason string is a fixed refresh_revoked/refresh_expired token derived from the error's reason discriminator and never from response text, so no token or upstream body can reach the store or a log; no default is turned on, and background warmup remains opt-in; the new write is strictly narrowing (an account can only move from healthy to needs-reauth), so a failure of this code cannot admit a credential that was previously excluded.

…grant

A Codex pool credential whose OAuth grant was revoked upstream kept
lastCodexValidationStatus: "ok" in codex-accounts.json and was reported as
healthy for as long as the install lived, while every request using it 401'd.

guardianSweep's pool branch already classified the failure -- it computed
`permanent` for a revoked/expired TokenRefreshError -- but spent it only on
widening an in-memory backoff delay. The persisted-verdict branch next to it
required `needsWarmup`, which is false in the default configuration, and
additionally excluded every TokenRefreshError, so the one class of failure that
proves the credential is dead was the one class that never reached the record.

Persist that verdict instead, independently of needsWarmup, and add a
lastCodexValidationTerminal marker so a dead grant is distinguishable from a
transient warmup failure. The write is fenced on the generation the sweep
actually observed, so a credential replaced mid-refresh is never branded by the
previous credential's failure.

The marker clears itself in both directions that disprove it: markCodexAccountValidated
clears it explicitly, and every credential write drops it because the record is
rebuilt from preservedValidationMetadata, which deliberately omits it. A refresh
that succeeds disproves "the grant was revoked", so one spurious invalid_grant
cannot brand a live account dead forever.

On the read side, projectCodexAccountHealth now reads that verdict and reports
reauth_required/refresh_failed -- the accurate statement, since only a re-login
recovers a revoked grant, and the existing union member already carries the
Codex reauth action. collectLocalCodexEntries is folded onto the same projector
rather than keeping its inlined copy, which is how the CLI would otherwise have
kept reporting the account healthy after the dashboard stopped.

Background warmup stays opt-in; no default-on probe is introduced.

Closes #4120
Diff-level roadmap for the three-layer chain (#4120 -> #3848 -> #3777), with the
wp1 design decisions recorded: why the terminal marker is an extra optional key
rather than a new status value, why it clears itself on every credential write,
why the generation fence declines rather than clobbers, and why the dashboard fix
is a server-side projection onto the existing reauth_required member.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 9, 2026 15:17
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 36 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: c9b20818-fb58-4818-ac54-55d143c323be

📥 Commits

Reviewing files that changed from the base of the PR and between 5669b96 and 2156fbe.

📒 Files selected for processing (11)
  • devlog/_plan/260909_codex_credential_health_chain/000_plan.md
  • devlog/_plan/260909_codex_credential_health_chain/010_wp1_terminal_verdict.md
  • devlog/_plan/260909_codex_credential_health_chain/020_wp2_quota_registration.md
  • devlog/_plan/260909_codex_credential_health_chain/030_wp3_anthropic_plan.md
  • src/codex/account-store.ts
  • src/oauth/health.ts
  • src/oauth/token-guardian.ts
  • src/types/accounts.ts
  • tests/codex-integration/codex-account-store.test.ts
  • tests/codex-integration/token-guardian.test.ts
  • tests/oauth/oauth-health.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 70 / 80

이 PR은 Codex 풀 계정에서 업스트림 OAuth grant가 이미 폐기됐는데도 codex-accounts.jsonlastCodexValidationStatus가 로그인 때의 "ok"로 남아, 대시보드·ocx status·ocx doctor가 그 계정을 계속 건강하다고 보여 주던 버그(#4120)를 고칩니다. 리포트에서는 가져온 12개 중 6개가 죽은 토큰인데도 화면은 살아 있는 것처럼 보였습니다. 요청은 401 token_revoked인데 UI만 초록이면 운영자가 원인을 못 찾습니다.

현재 dev HEAD(5669b96b7, tip #4127 overflow 분류 직후 / package 2.50.0)의 src/oauth/token-guardian.ts 풀 분기(guardianSweep 약 210–241행)를 보면, permanent(revoked/expired TokenRefreshError)를 계산한 뒤 인메모리 backoff 늘리기에만 쓰고, 디스크에 실패 판정을 쓰는 쪽은 needsWarmup && !(err instanceof TokenRefreshError)일 때만 탑니다. 기본 설정에서 codexWarmupEnabled는 꺼져 있어서 needsWarmup이 false이고, 게다가 정작 “grant가 죽었다”는 증거인 TokenRefreshError는 그 분기에서 제외됩니다. 읽기 쪽 projectCodexAccountHealth(src/oauth/health.ts 196–209행)는 validation 메타를 전혀 안 보고 재인증 플래그·쿨다운만 보므로, 기록에 남은 낡은 "ok"가 그대로 healthy로 나갑니다. collectLocalCodexEntries는 프로젝터를 호출하지 않고 같은 로직을 인라인 복사해 두어 CLI와 대시보드가 어긋날 여지도 있습니다.

이 PR의 쓰기는 터미널 TokenRefreshError면 warmup과 무관하게 lastCodexValidationStatus: "failed"와 새 키 lastCodexValidationTerminal을 남깁니다. 세 번째 status 값("revoked")을 안 넣은 이유는 타당합니다. isCredentialRecord"ok"|"failed"만 허용해서 모르는 status면 레코드 전체가 로드에서 빠질 수 있고, 롤백 시 계정 엔트리(자격 증명 포함)를 잃습니다. 선택 키는 구버전에서도 spread로 살아남습니다. 마커는 markCodexAccountValidated와 모든 credential write(preservedValidationMetadata에 키를 안 넣음)에서 지워지므로, 한 번의 가짜 invalid_grant로 영원히 죽은 표시가 남지 않게 설계했습니다. markCodexAccountValidationFailedexpectedGeneration 펜스는 스윕 도중 다른 writer가 자격 증명을 바꾼 경우 실패 판정을 새 자격 증명에 씌우지 않습니다.

읽기는 터미널 판정을 기존 union 멤버 reauth_required/refresh_failed로 투영합니다. 폐기된 grant는 재로그인만 고치므로 정확한 말이고, GUI는 서버 healthLabel이 아니라 gui/src/oauth-health-display.tshealth 객체를 다시 계산하므로 gui/ diff 없이 이미 호박색·재인증 액션이 뜹니다. collectLocalCodexEntries를 공유 프로젝터로 접은 것도 맞습니다. 이슈 기대 3(warmup 꺼도 주기적 재검증)은 기본 켜진 inference probe라서 의도적으로 안 넣었고, lastCodexValidatedAt 대시보드 컬럼도 GUI 별 유닛으로 미룬 설명이 분명합니다. 테스트는 기존 파일에만 붙여 layout.json 등록을 피했고, revoked persist / transient 무변경 / mid-refresh fence / projector·CLI까지 리포트 축을 잘 덮습니다.

src/oauth/token-guardian.ts (풀 catch, permanent) - HEAD는 permanent를 backoff에만 씀. 이 PR이 터미널이면 persist. warmup 기본 off와 맞물린 핵심 구멍
src/codex/account-store.ts (lastCodexValidationTerminal + generation fence) - 세 번째 status 대신 optional key. 롤백 안전. 성공 refresh/re-login이 마커를 지움
src/oauth/health.ts (projectCodexAccountHealth / collectLocalCodexEntries) - HEAD는 validation 메타 미참조 + CLI 인라인 복사. 공유 프로젝터로 합침
src/types/accounts.ts - 타입에 터미널 필드 추가. 스키마 문서와 맞는지 확인만
tests/codex-integration/* · tests/oauth/oauth-health.test.ts - 회귀가 이슈 재현·펜스·CLI까지 고정. 로컬 스위트는 NOT RUN, 원격 CI가 게이트
wp2(#3848)·wp3(#3777) - 같은 체인 plan에만 있고 이 PR 범위 밖. 섞지 말 것

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

  • exact-head CI 그린 후 단독 머지 가능한지(wp1 standalone 목표). wp2 충돌 시퀀스는 별 세션
  • 이슈 기대 3(주기적 재검증)을 정말 영원히 거절할지, 아니면 후속 opt-in 프로브로 둘지
  • reauth_required 재사용 vs 새 warning reason — GUI 비용 대비 카피가 충분한지(본문 주장은 설득력 있음)
  • revoked인데 아직 expiresAt이 먼 계정: refresh horizon에 안 걸리면 스윕이 아예 안 도는 경우(이슈 본문 needsRefresh false). 이 PR은 “refresh를 탄 뒤” 판정 저장에 초점. horizon 밖 죽은 계정은 여전히 스킵될 수 있는지 한 줄 확인

너의 추천
CI exact-head가 그린이면 #4140 merge#4120 닫기. 설계(optional terminal key, generation fence, projector 공유, GUI 무변경)가 HEAD의 실제 구멍과 맞다. wp2/wp3·기본 warmup 켜기·대시보드 컬럼은 이 PR에 넣지 말 것. 머지 전 “expiresAt이 멀어서 refresh조차 안 도는 폐기 계정”이 여전히 남을지 한 번만 짚고, 남으면 후속 이슈로 분리하라.

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

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 9, 2026
@lidge-jun
lidge-jun merged commit 71a0c30 into dev Sep 9, 2026
27 checks passed
@lidge-jun
lidge-jun deleted the codex/codex-terminal-validation-4120 branch September 9, 2026 15:56
lidge-jun added a commit to shaun0927/opencodex that referenced this pull request Sep 9, 2026
Brings the branch onto dev 71a0c30, which now contains the terminal
validation verdict from lidge-jun#4140. The overlapping hunks in account-store,
token-guardian and health were already reconciled in aeb86cb, so dev
merges clean here.

The 26 branch commits are authored by DaedalGames with an email
(noreply@daedalgames.github.io) that is not linked to a GitHub account, so
GitHub renders them as unlinked and they would credit nobody on the
contributor graph. These trailers carry the credit explicitly, using
addresses GitHub can resolve.

Co-authored-by: DaedalGames <daedal@daedal.games>
Co-authored-by: shaun0927 <70629228+shaun0927@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant