You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Account pool: continuing a thread after pool selection moves to a new account fails with 400 encrypted content could not be verified (account-bound reasoning ciphertext) #2247
Proxy and routing (account pool / thread affinity / encrypted reasoning replay)
Summary
With the ChatGPT account pool (codexAccountMode: pool) and two plus accounts registered, continuing an existing thread after pool selection moves to a different account fails with an upstream 400:
The encrypted content 6871...ef-0 could not be verified. Reason: Encrypted content could not be decrypted or parsed.
The request's historical reasoning items carry encrypted_content minted by the account that produced them (stored server-side on that account). When the pool routes the thread to a different account - after adding a new account (it becomes selected), or after the service restarts and in-memory thread affinity is lost - the new account's backend cannot verify the old account's ciphertext and rejects the whole request.
Reproduction
opencodex 2.28.0, codexAccountMode: pool, one existing plus account (main), start a conversation (any model, e.g. gpt-5.6-luna)
Add a second plus account (account pool) - the new account becomes selected
Continue the SAME thread in the Codex app (do not start a new session)
Request is routed to the new account namespace (openai-); upstream returns 400 Encrypted content could not be decrypted or parsed
Request log (redacted):
{
"provider": "openai-pe17f2a",
"admissionKind": "loopback",
"inboundProtocol": "responses",
"accountLogLabel": "pe17f2a",
"requestedModel": "gpt-5.6-luna",
"status": 400,
"errorCode": "invalid_request_error",
"upstreamError": "The encrypted content 6871...ef-0 could not be verified. Reason: Encrypted content could not be decrypted or parsed."
}
A brand-new session on the newly selected account works fine (first turn has no historical ciphertext).
Root cause analysis
Native ChatGPT responses carry encrypted_content on reasoning output items; the ciphertext is account-bound (server-side key/storage).
Codex threads replay history verbatim, so a continued thread sends the previous account's encrypted_content back upstream.
Pool affinity (resolveCodexAccountForThreadDetailed, CodexThreadAffinityExpiredError) keeps threads on their original account while the in-memory binding survives - but adding an account / restarting the proxy can re-select (ocx account current shows the new account as selected) or drop the binding, so the thread's next request is sent with the OTHER account's credentials and history.
The affinity-expired path returns 409 start a new session, but in this flow the request is admitted and forwarded, and the failure surfaces as an opaque upstream 400 instead.
Suggested fix
Persist thread-to-account affinity across restarts (and across account-pool membership changes) so existing threads never silently switch accounts.
Defense in depth: when pool routing selects an account different from the one that produced the thread's historical encrypted_content items, either refuse with the existing 409 (Codex thread account affinity expired; start a new session) or scrub the account-bound ciphertext from the forwarded history.
Confirmed against current dev; this is a real account-pool continuation bug.
The current protection is process-local only. threadAccountMap is lost on restart, and resetCodexRoutingForManualSelection deliberately clears every binding. resolveCodexAccountForThreadDetailed can then bind the same x-codex-parent-thread-id to the newly selected account and forward replayed reasoning items containing ciphertext minted under the old account. The upstream 400 is therefore expected from the current code, but it is not an acceptable user-facing boundary.
#2248 is related but must not close this report: that draft records an in-process durable route transition and explicitly leaves fresh-process / missing-record cases unchanged. It does not cover restart, manual account selection, or an affinity map cleared during account lifecycle changes.
Planned repair:
Persist a bounded, privacy-safe thread-to-account affinity record across proxy restarts, retaining the existing 24-hour idle TTL and 2,048-entry cap.
Bind the record to the live account credential generation, and discard records only when the owning account is removed, reauthenticated, or otherwise no longer valid.
Preserve existing threads across manual active-account changes instead of silently rebinding them. New threads may use the newly selected account normally.
If a known existing binding is expired or invalid, return the existing actionable 409 before any upstream request rather than forwarding account-bound ciphertext to a different account.
Add focused regressions for restart hydration, manual/add-account selection, account removal/credential replacement, TTL/LRU bounds, and the no-upstream-on-409 invariant.
I am taking this issue. I will keep it open until the exact-head fix is reviewed and merged into dev.
재현이 지금 dev HEAD 84d5523a9에서 그대로임. 보호가 프로세스 로컬뿐임. threadAccountMap (src/codex/routing.ts:163)이 in-memory Map이라 재시작하면 증발함. 24시간 idle TTL (CODEX_THREAD_AFFINITY_IDLE_TTL_MS:112)이랑 2048 캡 (:113)은 그 맵 안에서만 셈. 디스크 없음. ㅋㅋ 껏다 켜면 친밀도 없음.
수동 선택이 더 세게 비움. resetCodexRoutingForManualSelection (:674-675)이 clearThreadAccountMap()으로 전 스레드 바인딩을 지움. PUT /api/codex-auth/active (src/codex/auth-api.ts:1602)가 그거 호출함. 로그인 커밋 persistNewCodexAccount (:473)은 맵을 안 건드림. 계정 추가 뒤에 선택 PUT이 따라오면 기존 스레드가 전부 unbound가 됨. ocx account current가 새 계정을 찍는 그 흐름임.
unbound가 409가 아님. resolveCodexAccountForThreadDetailed (routing.ts:1489)는 엔트리가 있고 TTL이 지난 때만 { status: "expired" } (:1504-1506). 맵이 비면 :1551 전략 픽 → :1554getEffectiveActiveCodexAccountId로 새 계정에 붙고 bindThreadAffinity가 같은 x-codex-parent-thread-id를 새 계정에 다시 묶음. 409는 auth-context.ts:389가 expired일 때만 던짐. Responses는 src/server/responses/core.ts:1216-1219에서 "start a new session". 이 재현은 그 분기를 안 탐. 요청이 통과하고 업스트림 400이 남.
ciphertext는 계정 묶임임. 네이티브 ChatGPT reasoning 아이템의 encrypted_content는 민팅한 계정 서버키임. Codex가 히스토리를 그대로 에코함. 새 openai-<id> 네임스페이스가 옛 계정 blob을 검증 못 해서 The encrypted content ... could not be verified 남. 새 세션은 히스토리 없어서 통과. 본문 맞음.
#2229 리셰이프랑 다른 구멍임. 지금 devreasoningItemToSummaryShape (src/server/responses-reasoning-summary-rewrite.ts:35-44)는 encrypted_content 가드가 없음. content를 지우고 합성 summary를 넣음. 그 PR은 닫혔고 dev에 안 들어옴. 그건 Grok/DeepSeek 저장 셰이프 하드닝임. 여기 400은 셰이프가 멀쩡해도 계정만 바뀌면 터짐. #2254가 그 가드랑 크로스-백엔드 스트립을 들고 와도 프로바이더 destination identity임. Codex 풀 계정 친밀도 아님. in-process라 재시작 구멍도 같음. #2248을 이 이슈에 접지 말라는 Ingwannu 말 그대로임. #2254Closes #2240이지 #2247 아님.
#2188 L1–L9는 이미 dev임. 사이드카 무관. #2190x_search 무관. types.ts 스플릿 안 씹힘. routing/auth-context임. 2.28 태그 블로커는 아닌데 풀 유저는 스레드가 죽음. 점수 71. 트래킹 #2240(76)보다 낮음. 기본 Grok 패스스루가 아님. 그래도 확인된 400이라 낮게 주면 안 됨.
해결방안: 친밀도를 재시작 넘어 남기는 bounded 레코드. 기존 24h TTL·2048 캡 유지. credential generation에 묶고, 계정 삭제/재인증일 때만 버림. 수동 선택은 기존 스레드를 다시 묶지 말 것. 새 스레드만 새 계정. 알려진 바인딩이 만료/무효면 업스트림 전에 지금 409. 회귀는 restart hydration / 수동·추가 선택 / 계정 제거 / TTL·LRU / no-upstream-on-409. #2254랑 한 PR로 합치지 말 것. 스플릿이 routing 맵을 옮기면 리베이스하지 말고 닫고 다시 짜라. 지금은 그 정도 아님.
Fixed on dev via #2269. Two layers: (1) the serving-identity pre-flight already strips prior-account reasoning ciphertext/compaction blobs when the pool re-routes a thread — now locked by an end-to-end two-account regression (second send observed with encrypted_content stripped) plus a restart-style durable-identity regression; (2) the recovery path missed your exact rejection shape — ChatGPT returns invalid_request_error with code: null and the could-not-be-verified message, which the matcher now recognizes narrowly, so even a history the process never served (restart, eviction) rebuilds once and refetches instead of surfacing the 400. Will ship in the next release (2.28.0).
Activity
Ingwannu commentedon Aug 21, 2026
Confirmed against current
dev; this is a real account-pool continuation bug.The current protection is process-local only.
threadAccountMapis lost on restart, andresetCodexRoutingForManualSelectiondeliberately clears every binding.resolveCodexAccountForThreadDetailedcan then bind the samex-codex-parent-thread-idto the newly selected account and forward replayed reasoning items containing ciphertext minted under the old account. The upstream 400 is therefore expected from the current code, but it is not an acceptable user-facing boundary.#2248 is related but must not close this report: that draft records an in-process durable route transition and explicitly leaves fresh-process / missing-record cases unchanged. It does not cover restart, manual account selection, or an affinity map cleared during account lifecycle changes.
Planned repair:
I am taking this issue. I will keep it open until the exact-head fix is reviewed and merged into
dev.lidge-jun commentedon Aug 21, 2026
리뷰 · 우선순위 71 / 80
재현이 지금
devHEAD84d5523a9에서 그대로임. 보호가 프로세스 로컬뿐임.threadAccountMap(src/codex/routing.ts:163)이 in-memoryMap이라 재시작하면 증발함. 24시간 idle TTL (CODEX_THREAD_AFFINITY_IDLE_TTL_MS:112)이랑 2048 캡 (:113)은 그 맵 안에서만 셈. 디스크 없음. ㅋㅋ 껏다 켜면 친밀도 없음.수동 선택이 더 세게 비움.
resetCodexRoutingForManualSelection(:674-675)이clearThreadAccountMap()으로 전 스레드 바인딩을 지움. PUT/api/codex-auth/active(src/codex/auth-api.ts:1602)가 그거 호출함. 로그인 커밋persistNewCodexAccount(:473)은 맵을 안 건드림. 계정 추가 뒤에 선택 PUT이 따라오면 기존 스레드가 전부 unbound가 됨.ocx account current가 새 계정을 찍는 그 흐름임.unbound가 409가 아님.
resolveCodexAccountForThreadDetailed(routing.ts:1489)는 엔트리가 있고 TTL이 지난 때만{ status: "expired" }(:1504-1506). 맵이 비면:1551전략 픽 →:1554getEffectiveActiveCodexAccountId로 새 계정에 붙고bindThreadAffinity가 같은x-codex-parent-thread-id를 새 계정에 다시 묶음. 409는auth-context.ts:389가expired일 때만 던짐. Responses는src/server/responses/core.ts:1216-1219에서 "start a new session". 이 재현은 그 분기를 안 탐. 요청이 통과하고 업스트림 400이 남.ciphertext는 계정 묶임임. 네이티브 ChatGPT reasoning 아이템의
encrypted_content는 민팅한 계정 서버키임. Codex가 히스토리를 그대로 에코함. 새openai-<id>네임스페이스가 옛 계정 blob을 검증 못 해서The encrypted content ... could not be verified남. 새 세션은 히스토리 없어서 통과. 본문 맞음.#2229 리셰이프랑 다른 구멍임. 지금
devreasoningItemToSummaryShape(src/server/responses-reasoning-summary-rewrite.ts:35-44)는encrypted_content가드가 없음. content를 지우고 합성 summary를 넣음. 그 PR은 닫혔고dev에 안 들어옴. 그건 Grok/DeepSeek 저장 셰이프 하드닝임. 여기 400은 셰이프가 멀쩡해도 계정만 바뀌면 터짐. #2254가 그 가드랑 크로스-백엔드 스트립을 들고 와도 프로바이더 destination identity임. Codex 풀 계정 친밀도 아님. in-process라 재시작 구멍도 같음. #2248을 이 이슈에 접지 말라는 Ingwannu 말 그대로임. #2254Closes #2240이지 #2247 아님.#2188 L1–L9는 이미
dev임. 사이드카 무관. #2190x_search무관. types.ts 스플릿 안 씹힘. routing/auth-context임. 2.28 태그 블로커는 아닌데 풀 유저는 스레드가 죽음. 점수 71. 트래킹 #2240(76)보다 낮음. 기본 Grok 패스스루가 아님. 그래도 확인된 400이라 낮게 주면 안 됨.해결방안: 친밀도를 재시작 넘어 남기는 bounded 레코드. 기존 24h TTL·2048 캡 유지. credential generation에 묶고, 계정 삭제/재인증일 때만 버림. 수동 선택은 기존 스레드를 다시 묶지 말 것. 새 스레드만 새 계정. 알려진 바인딩이 만료/무효면 업스트림 전에 지금 409. 회귀는 restart hydration / 수동·추가 선택 / 계정 제거 / TTL·LRU / no-upstream-on-409. #2254랑 한 PR로 합치지 말 것. 스플릿이 routing 맵을 옮기면 리베이스하지 말고 닫고 다시 짜라. 지금은 그 정도 아님.
이 댓글은 grok-bot이 작성했습니다
lidge-jun commentedon Aug 21, 2026
Fixed on dev via #2269. Two layers: (1) the serving-identity pre-flight already strips prior-account reasoning ciphertext/compaction blobs when the pool re-routes a thread — now locked by an end-to-end two-account regression (second send observed with encrypted_content stripped) plus a restart-style durable-identity regression; (2) the recovery path missed your exact rejection shape — ChatGPT returns invalid_request_error with code: null and the could-not-be-verified message, which the matcher now recognizes narrowly, so even a history the process never served (restart, eviction) rebuilds once and refetches instead of surfacing the 400. Will ship in the next release (2.28.0).
fix(responses): leave encrypted reasoning to #2247 and own only the c…
fix(responses): leave encrypted reasoning to #2247 and own only the c…
fix(responses): drop account-bound continuation when the serving acco…
fix(responses): drop account-bound continuation when the serving acco…