Repository navigation
[Bug]: a time-valid stored Codex pool token is quarantined on the first Responses 401 #2887
Description
Client or integration
Codex App
Area
Authentication and account pool
Summary
An ordinary stored Codex pool credential can be quarantined on the first pre-stream Responses 401, even when its access token is still time-valid and a refresh token is available.
This is distinct from native __main__ handling in #2221/#2848 and WHAM usage refresh handling in #1932/#2021. Ordinary stored accounts resolve as authCtx.kind === "pool"; their time-valid access token bypasses refresh, while the bounded 401 refresh-and-replay path currently only handles main-pool. The first upstream 401 therefore reaches the terminal credential-failure path, marks the stored account needsReauth, and clears its affinity without trying one same-account refresh.
Expected behavior: for an ordinary stored pool account, the first pre-stream/header 401 should perform one generation-fenced, single-flight forced refresh and rebuild the same account's request exactly once. Only a terminal refresh failure or a second 401 from the matching credential generation should quarantine that account.
Reproduction
- Store an ordinary Codex pool account with an access token, refresh token, account ID,
needsReauth: false, andexpiresAt > Date.now() + 60_000. - Select or fix that account for a
/v1/responsesrequest. - In a deterministic test harness, return
401for the old bearer token, return fresh rotated tokens from the OAuth token endpoint, and return200for the replay using the new bearer token. - Observe the current behavior: the old bearer is sent once, the refresh endpoint is not called, the client receives
401, and the account is markedneedsReauthwith its affinity cleared.
The same gap applies to /v1/responses/compact.
Suggested acceptance boundaries:
- Refresh and replay at most once, on the same ordinary pool account, for a first pre-stream/header
401. - Fence refresh, persistence, and final quarantine by the rejected account ID and credential generation so a stale
401cannot overwrite or quarantine a concurrently replaced credential. - Coalesce concurrent same-generation refreshes into one token-endpoint call and one generation increment.
- Preserve fixed-account, pin, and affinity semantics; do not select a different account for the first replay.
- Do not quarantine on cancellation, lock contention, network failure, or another transient refresh error.
- Do not replay a mid-stream failure, exceed the request's total recovery budget, or expose access/refresh tokens in logs or errors.
Version
2.35.0; also present on dev at 8621acb8756c1656676452f4a6c853d2d720c08f
Operating system
Windows 11 Pro 10.0.26200
Provider and model
OpenAI Codex account pool / native GPT-5.6 family
Logs or error output
Synthetic harness result:
upstream sends: 1 (old bearer)
OAuth refresh calls: 0
client response: 401
stored account: needsReauth=true; affinity removedScreenshots and supporting files
Not required; the reproduction is deterministic and contains no account data.
Redacted configuration
{
"codexAuth": {
"mode": "pool",
"accountSelectionStrategy": "fill-first"
}
}Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
lidge-jun commentedon Aug 29, 2026
리뷰 · 우선순위 74 / 80
이 이슈가 말하는 버그는 한 줄로 요약된다. Codex 풀에 저장해 둔 일반 계정(
authCtx.kind === "pool")의 액세스 토큰이 아직 만료 시각 안인데, 첫 Responses 사전 스트림 401만 맞아도 바로needsReauth로 격리된다. 리프레시 토큰이 있어도 OAuth 토큰 엔드포인트를 한 번도 안 부른다. 제보자가 적어 둔 합성 하네스 결과(업스트림 1회, OAuth refresh 0회, 클라이언트 401, 계정 needsReauth=true, affinity 제거)가 그 동작을 그대로 보여 준다. 버전은 2.35.0이고, 지금devHEAD(8621acb87, 패키지 2.36.0)에도 같은 구멍이다. #2221/#2848의 네이티브__main__처리나 #1932/#2021의 WHAM 사용량 새로고침과는 다른 길이다.지금 코드가 그렇게 동작하는 이유는 명확하다.
src/server/responses/core.ts의 패스스루 복구 루프는 401 재시도 분기를authCtx.kind === "main-pool"일 때만 연다. 그 안에서refreshNativeMainForwardAuth로 메인 계정을 한 번 강제 갱신하고 같은 요청을 다시 보낸다. 일반 저장 풀 계정은kind: "pool"이라 이 분기에 아예 못 들어간다.src/server/responses/compact.ts도 같다. compact 401 재시도도main-pool만 본다. 그래서/v1/responses와/v1/responses/compact둘 다, 일반 풀 계정의 첫 헤더 401은 곧바로 단말 자격 증명 실패로 떨어진다.그다음 단말 경로는 계정을 격리한다.
src/codex/routing.ts에서 outcome이 401/403이면credential로 분류하고,markAccountNeedsReauth를 호출한 뒤clearThreadAccountMapForAccount로 affinity를 지운다. 액세스 토큰이 시간상 아직 살아 있어도, “한 번 refresh-and-replay”를 안 했기 때문에 같은 결과가 난다. 풀 모드에서 시간 유효 토큰은 갱신을 건너뛰는 쪽이 정상인데, 그 가정과 401 복구가 맞춰져 있지 않다. xAI/GitHub Copilot/Kiro 쪽은 같은 파일에isOAuth401ReplayProvider로 사전 스트림 401 → 강제 refresh → 1회 재구성 재시도가 이미 있다. Codex 일반 풀만 빠졌다.이슈가 적어 둔 수락 경계는 지금
dev방향과도 잘 맞는다. 같은 계정에서 최대 1회, 거절된 accountId와 credential generation으로 펜스, 같은 generation의 동시 refresh는 한 번으로 합치기, fixed/pin/affinity는 첫 재시도에서 다른 계정으로 바꾸지 않기, 취소·락 경합·네트워크 같은 일시 실패로는 격리하지 않기, mid-stream 실패는 재시도하지 않기. 메인 계정 경로(main-pool)가 이미 비슷한 모양을 갖고 있으니, 일반pool에 같은 계약을 확장하는 쪽이 자연스럽다. types/config 분할과 무관하고, 닫을 중복도 없다.src/server/responses/core.ts 약 3815-3820줄 - 401 refresh-and-replay가
main-pool만 본다.kind === "pool"인 일반 저장 계정은 첫 401에서 이 분기를 건너뛴다.src/server/responses/compact.ts 약 679-684줄 - compact도 같은
main-pool가드다. 이슈가 적은/v1/responses/compact구멍이 여기다.src/codex/routing.ts 약 2146-2157줄 - 401을 credential로 보고 바로
needsReauth와 affinity 제거를 한다. 같은 계정 1회 refresh를 먼저 시도하는 문이 없다.경로/oauth 저장소 - generation 펜스와 single-flight는
src/oauth/store.ts의mergeAccountCredential/markAccountNeedsReauthIfGeneration쪽에 이미 재료가 있다. 새 저장 형식을 만들기보다, Responses 복구가 그 계약을 쓰게 연결하는 일이다.경로/#2221 #2848 #1932 #2021 - 메인/WHAM 이야기는 이미 따로 있다. 이 이슈를 그쪽으로 합치지 마라. 대상은 일반 저장 풀 계정이다.
메인테이너의 판단이 필요한 지점
pool401 복구를main-pool과 같은 헬퍼로 합칠지, pool 전용 refresh-and-replay를 둘지너의 추천
이 이슈는 열어 두고, 작은 버그 PR을 받는다.
core.ts와compact.ts에서 일반pool도 사전 스트림/헤더 401에 한 해 generation 펜스된 강제 refresh + 같은 계정 1회 재시도를 하게 하라. 성공하면 needsReauth를 올리지 마라. refresh 단말 실패나 같은 generation의 두 번째 401에서만 지금 routing 격리를 타게 하라. 제보 수락 경계를 테스트로 고정하라. types/config 분할 때문에 닫을 대상이 아니다.이 댓글은 grok-bot이 작성했습니다
lidge-jun commentedon Aug 29, 2026
Fixed on
devat0f4cd2a0b(PR #2889).Your diagnosis was exact: ordinary stored accounts resolve as
authCtx.kind === "pool", their time-valid access token bypasses refresh, and the bounded 401 refresh-and-replay path only handledmain-pool, so the first upstream 401 reached the terminal credential path and marked the accountneedsReauthwith its affinity cleared. Both/v1/responsesand/v1/responses/compactnow perform one generation-fenced forced refresh and one same-account replay./v1/chat/completionsis covered too, since it bridges intohandleResponses.Every acceptance boundary you listed is met, and reviewing against them turned up five things the first implementation got wrong:
Retry-After. A bareinvalid_grantis now correctly terminal rather than retried forever.One correction to the report:
needsReauthis a process-local set rather than a persisted credential-store field, so the account recovered on restart. Inside a running proxy the effect was exactly as you described — out of rotation with its affinity gone.Still open and tracked separately: the sidecar recorders (search, images, live, vision) record pool 401s without the generation fence. That is pre-existing behavior, but a forced refresh makes generation bumps more frequent, so the window where a stale sidecar 401 could retire a freshly refreshed credential is wider than before.
Thank you for the deterministic harness description — the regression suite is built directly on it.
pool: name the account when a refresh fails or its models vanish
fix(codex): classify refresh failures on the structured code and stop…
pool: name the account when a refresh fails or its models vanish
fix(codex): classify refresh failures on the structured code and stop…