Skip to content

[OpenAI/Codex] K12 denial under detail.code is misclassified and cross-account thread switching returns 403 #2046

Closed

Description

@jojodat

Client or integration

Codex App

Provider or upstream service

OpenAI/Codex (K12)

OpenCodex version

2.24.1

Endpoint or capability

Authentication and account/thread routing

Current behaviour

The published denial classifier recognizes structured codes only at the top level or under error.code. The observed response shape is:

{"detail":{"code":"codex_workspace_access_denied","message":"..."}}

A local regression test returned denial = null before the patch. Adding an own-property check for detail.code returned denial = "workspace", and the incorrect needs-reauth state disappeared.

A fresh task bound exactly to the K12 selector completed with HTTP 200 and was routed to the K12 pool account. Switching an existing Plus-bound task to the same exact K12 selector produced four reproducible HTTP 403 responses without fallback.

Expected behaviour

K12 should continue to work in clean tasks, and workspace denials nested under own-property detail.code must be classified as workspace denials rather than credential failures.

When an existing Plus-bound thread is switched to an exact K12 selector, OpenCodex must either start clean/remove incompatible thread metadata or return a clear thread/account incompatibility. It must never mark the account needs-reauth for this case.

Minimal redacted request or reproduction

1. Reauthenticate a valid K12 account and verify the upstream plan is K12.
2. Start a fresh task explicitly bound to that K12 account and select `k12test/gpt-5.6-sol`; observe HTTP 200.
3. Start a task under Plus, then switch that existing task to the same exact K12 selector.
4. Observe reproducible HTTP 403 responses with no fallback.
5. Unit-test denial classification with `{ detail: { code: "codex_workspace_access_denied" } }`; current result is `null` instead of `workspace`.

Actual response or error

Fresh exact-K12 task: HTTP 200.

Existing Plus-bound task switched to exact K12: four reproducible HTTP 403 responses, no fallback. The nested denial envelope is:


{"detail":{"code":"codex_workspace_access_denied","message":"..."}}


OpenCodex incorrectly transitions to `needs-reauth` unless `detail.code` is handled.

Upstream documentation

No public upstream specification appears to document this private WHAM denial envelope. The concrete client requirement is established by the observed response shape, successful clean-task routing, and the reproducible cross-account/thread case.

Suggested mapping or implementation notes

Extend structured denial extraction to accept own-property detail.code, alongside the existing top-level and error.code paths. Add a regression test: an existing Plus-bound thread switched to an exact K12 selector must either shed incompatible metadata/start clean or return an explicit thread/account incompatibility, never needs-reauth.

Additional context and attachments

Follow-up to #1789. Reauthentication succeeded, credentials were valid, and the authenticated upstream check returned HTTP 200 with the K12 plan. This confirms that K12 works through OpenCodex in a clean task; the remaining failure is specific to cross-account/thread metadata.

No additional private data should be necessary. If maintainers need a diagnostic build tested, I can offer one anonymized K12 run.

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    on Aug 18, 2026
  2. Ingwannu commented on Aug 18, 2026

    @Ingwannu
    Owner

    Confirmed on current dev. structuredDenialCode currently accepts an own string code only at the document root or under error; the reported own-property detail.code shape therefore falls through and a 403 is misclassified as a credential failure.

    I will fix the classifier narrowly to accept only an ordinary object-valued detail with an own string code, add positive and fail-closed regressions, and verify that the routing outcome preserves the credential/account binding instead of marking it for reauthentication.

    The existing-thread 403 is a separate compatibility boundary: the classifier fix can stop the false reauthentication state, but it cannot invent upstream workspace access. I will keep that part open until the exact thread metadata carried across the Plus-to-K12 switch is identified rather than guessing at a destructive thread reset.

  3. Ingwannu commented on Aug 18, 2026

    @Ingwannu
    Owner

    Implemented the verified classifier fix in #2055.

    The PR accepts only exact own string codes under object-valued detail, preserves the existing fail-closed behavior for malformed or ambiguous bodies, and adds positive workspace/entitlement plus negative non-string coverage. Focused tests (166), typecheck, privacy scan, and the exact-range security diff review passed.

    I am leaving this issue open for the second reported behavior: identifying which upstream thread/workspace metadata makes an existing Plus-bound thread incompatible after an exact K12 switch. #2055 intentionally does not reset or discard thread state without that evidence.

  4. yzxcj797 commented on Aug 18, 2026

    @yzxcj797
    Contributor

    Fixed the classification half — structuredDenialCode now performs the own-property code lookup at the top level, under error, and under detail (the envelope K12 actually sends: {"detail":{"code":"codex_workspace_access_denied",...}}). Regression tests pin the detail-nested workspace and entitlement denials, plus a detail object carrying a non-denial code still reading denial = null (fail-closed preserved).

    The cross-account thread-switching half (Plus-bound task switched to an exact K12 selector → 403 without fallback) is not included: that's a thread-metadata/account-affinity design decision — whether to start clean on incompatible switch or return a clear incompatibility error — which the maintainers should weigh in on before I guess.

  5. lidge-jun commented on Aug 19, 2026

    @lidge-jun
    Owner

    Classification half is now on dev: #2055 merged as 2648ffa. detail.code workspace/entitlement denials are classified fail-closed and no longer flip accounts to needs-reauth.

    Remaining half of this issue — the cross-account thread-switch 403 (Plus-bound thread switched to an exact K12 selector gets four 403s with no fallback or clear incompatibility signal) — is not addressed by #2055 and stays open here.

  6. jojodat commented on Aug 19, 2026

    @jojodat
    Author

    Additional reproduction on OpenCodex 2.26.0:

    Clarification: k12test is only the arbitrary local OpenCodex account namespace for the authenticated K12 account; gpt-5.6-sol is the actual model ID. It is not a custom provider or model. An account-qualified selector such as k12test/gpt-5.6-sol pins routing to that account, whereas a plain model ID uses Pool mode and may fail over to Plus.

    • An existing K12-origin task remains exact-bound to k12test/gpt-5.6-sol and continues to complete with HTTP 200.
    • Five recent fresh-task launches, plus one isolated control run, used a plain model in Pool mode. OpenCodex selected K12 first; K12 returned one or two HTTP 403 permission_denied responses, after which the same launch fell back to Plus and completed with HTTP 200.
    • The isolated control reproduced this while the existing exact-K12 task continued succeeding concurrently.

    This narrows the remaining behavior: a newly created Codex task is not necessarily “clean” at the upstream account/thread-affinity level. The evidence suggests that the new task already carries metadata compatible with Plus before OpenCodex selects K12 from the pool. K12 rejects that metadata, while the Plus fallback succeeds. A plain model masks the incompatibility through pool failover; an exact K12 selector cannot.

    Please add a regression covering a newly created Codex task whose first request is pool-selected to K12. The task/thread metadata should be compatible with the selected account; otherwise OpenCodex should skip K12 for that task or return a clear account/thread incompatibility.

    No further private data should be necessary. I can run one anonymized diagnostic build if needed.

  7. lidge-jun commented on Aug 19, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 46 / 80

    K12 403이 {detail:{code:"codex_workspace_access_denied"}} 모양이면 분류기가 못 읽고 needs-reauth로 떨어졌다. 새 태스크를 K12 셀렉터에 묶으면 200이고, Plus로 시작한 태스크를 같은 K12 셀렉터로 바꾸면 403이 네 번 나고 폴백이 없다. 자격 있는 계정 재로그인을 강요하는 쪽이라 46이다. 기본 설치 직후 사고는 아니고, 계정 전환/워크스페이스 거절 호환이다.

    분류기 일부는 현재 dev에서 이미 넓혀져 있다. src/codex/quota-rejection.ts의 structuredDenialCode()는 최상위 code 다음에 own-property error와 detail을 본다. {detail:{code:"codex_workspace_access_denied"}}는 이제 workspace가 되어야 한다. WORKSPACE_DENIAL_CODES에 그 문자열이 있다. src/codex/routing.ts의 classifyCodexUpstreamOutcome()는 403+denial이면 credential이 아니라 workspace다. 2.24.1에서 denial = null이던 단위 테스트는 dev에서 다시 돌려야 한다.

    스레드 전환 403은 분류기와 다른 층이다. Plus 태스크에 묶인 스레드 메타데이터(워크스페이스/스레드 id)를 K12 계정이 받아 거절하는 모양이다. 코드에서 “Plus 스레드를 K12로 바꿀 때 메타를 지운다”는 함수는 찾지 못했다. 패치 위치를 새로 짓지 않는다. 새 태스크가 200인 것과 맞으면, 기존 스레드를 재사용하지 말고 명확한 불일치 에러를 주는 쪽이 안전하다.

    needs-reauth로 올리면 유효한 K12 자격 증명을 폐기한다. #1789 주석이 그 루프를 경고한다. 분류기가 workspace를 주면 재로그인 루프는 닫혀야 한다. 그래도 403이 폴백 없이 네 번이면, 분류 다음의 재시도/스레드 바인딩이 워크스페이스 거절을 소모성 재시도로 취급하는지를 봐야 한다.

    해결방안

    분류기 회귀 테스트로 {detail:{code:"codex_workspace_access_denied"}} → denial: "workspace"를 dev에서 확인한다. 이미 structuredDenialCode()가 그 경로를 보면 분류기 패치는 중복이다. 남은 건 Plus-bound 스레드를 정확 K12 셀렉터로 바꿀 때다. 호환되지 않는 스레드 메타를 지우거나, 계정 불일치를 명시하고 needs-reauth를 쓰지 않는다. 그 변환 함수 이름은 추측하지 않는다. 새 태스크 정확 바인딩 200은 유지한다. 계정 id·토큰·스레드 id는 로그에 남기지 말고 상태 코드와 detail.code만 남긴다.

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

  8. lidge-jun commented on Aug 19, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 66 / 80 · 유지

    점수 정정임. 46 → 66. K12가 {"detail":{"code":"codex_workspace_access_denied"}} 로 거절하는데, 분류기가 top-level/error.code만 봐서 denial = null → needs-reauth로 감. 새 작업은 200. Plus 스레드를 K12로 바꾸면 403 네 번, 폴백 없음. 계정은 멀쩡함 (#1789 후속).

    유지함. 자격 만료가 아닌데 재로그인으로 몰면 풀이 죽음. #2047(숏윈도우 쿼타 드롭)이랑 이웃. draft #2063이 detail.code 쪽.

    해결방안: own-property detail.code도 workspace denial로. Plus→K12 전환은 메타 버리거나 명시 비호환. needs-reauth로 가지 말 것.

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

  9. jojodat commented on Aug 20, 2026

    @jojodat
    Author

    Additional evidence from OpenCodex 2.28.0:

    • A fresh ephemeral Codex task started directly with the exact selector k12test/gpt-5.6-sol completed with HTTP 200 on the first attempt. The routing record shows explicit-account, the K12 account namespace, and no fallback.
    • An existing task whose first successful response came from Plus was then switched to the same exact K12 selector. It produced six reproducible HTTP 403 workspace denials without fallback, while the K12 account remained usable and was not marked needs-reauth.
    • A concurrently running K12-origin task continued returning HTTP 200.

    This separates the cases more clearly: fresh exact-K12 routing works; the failure appears after Plus has successfully handled the task.

    I also tested several possible causes independently. A standalone session_id, prompt_cache_key, x-codex-parent-thread-id, and replay of the stored Plus continuation items were each accepted by K12. None is sufficient by itself to reproduce the denial.

    The strongest remaining code-level candidate is the combined forwarded affinity metadata:

    • FORWARD_HEADERS includes session_id, thread-id, x-codex-parent-thread-id, x-codex-turn-metadata, x-codex-turn-state, window/installation headers, and other caller metadata.
    • materializeCodexUpstreamAuth copies those headers first and, for a selected pool account, replaces only authorization and chatgpt-account-id.
    • Current Codex turn metadata contains fields including session_id, thread_id, parent_thread_id, and forked_from_thread_id.

    Therefore a Plus-to-K12 switch changes the credentials/account header while potentially retaining the original task's complete thread/turn affinity envelope. Header presence alone is not the problem—working and failing requests both carry turn metadata—but the retained account-bound values or their combination are a concrete target for instrumentation.

    Suggested regression:

    1. Let Plus successfully handle a task.
    2. Switch that same task to an exact K12 selector.
    3. Verify that OpenCodex either removes/rewrites incompatible account-affinity metadata before the upstream request or fails locally with a clear thread/account incompatibility.
    4. A workspace denial must not be retried six times, must not trigger fallback for an exact selector, and must never mark valid credentials for reauthentication.

    A diagnostic build could safely log only the presence and stable hashes of individual affinity fields before and after account substitution. No raw IDs, metadata values, credentials, or other private data are needed. I can run one anonymized K12 diagnostic test if useful.

  10. Ingwannu commented on Aug 20, 2026

    @Ingwannu
    Owner

    Thank you — the 2.28.0 result rules out the four obvious single-field explanations. I also verified the current implementation: materializeCodexUpstreamAuth() copies the complete curated FORWARD_HEADERS set first, then replaces only authorization and chatgpt-account-id for the selected account. The remaining boundary is therefore the combined affinity envelope or provenance, not the mere presence of one of the four fields you tested.

    I do not want to “fix” this by deleting all turn metadata. That would be destructive, could break valid continuation/subagent behavior, and the current evidence does not justify it.

    I am taking you up on the anonymized diagnostic run. The diagnostic must be opt-in and observational only:

    • no raw authorization, account IDs, thread/session IDs, turn metadata, request bodies, or continuation items;
    • a fresh in-memory random HMAC key per OCX process, so values can be compared within one run but cannot be correlated across restarts or dictionary-reversed from a durable unsalted hash;
    • presence plus keyed digests for each forwarded affinity header before and after account substitution;
    • bounded top-level key presence/digests for JSON turn metadata, with malformed/oversized values reported only as a category;
    • the selected route/account mode and denial class, but no account identity;
    • no header stripping, replay, fallback, account switching, or automatic thread reset.

    The useful capture is one Plus-success turn followed by the failing exact-K12 turn in the same OCX process. That will tell us which affinity values remain bound across the credential substitution without exposing their contents. I am keeping this open and will link the diagnostic change here; the eventual production fix will be limited to the field combination the capture actually proves.

  11. self-assigned this
    on Aug 20, 2026
  12. Ingwannu commented on Aug 20, 2026

    @Ingwannu
    Owner

    The observation-only diagnostic is now in #2196 and is ready for maintainer/security review.

    It is deliberately narrower than a fix: provider debug emits one [ocx:codex:affinity] record after each canonical ChatGPT forward response. The record contains only fixed header names, coarse size buckets, and equality tags under a fresh random per-process HMAC key. It excludes credentials, account IDs, attestation, raw session/thread IDs, raw turn metadata, request bodies, and continuation items. Oversized values are categorized without hashing, and the code cannot strip metadata, retry, switch accounts, fall back, or reset a thread.

    After #2196 is reviewed and available in a diagnostic build, the useful reproduction is:

    ocx debug provider on
    ocx debug provider logs -f
    

    In the same OCX process, capture one successful Plus turn and then the failing exact-K12 turn from the same task. Please send only the two [ocx:codex:affinity] JSON records plus the HTTP status/denial class; do not include surrounding provider logs. Then disable capture with ocx debug provider off.

    Focused tests, typecheck, privacy scan, and the 393-page docs build pass on the PR head. Because this touches the forwarded header/credential boundary, I have requested explicit review from @lidge-jun and @Wibias and will not self-merge it.

  13. Ingwannu commented on Aug 21, 2026

    @Ingwannu
    Owner

    The privacy-bounded diagnostic from #2196 is now merged into dev as 98848575df9b9343a901dd4d1c5b23026b752931.

    @jojodat, the next useful step is the same-process capture you offered:

    ocx debug provider on
    ocx debug provider logs -f
    

    In one OCX process, please record:

    1. one successful Plus turn for the task;
    2. the failing exact-K12 turn for that same task;
    3. only the two [ocx:codex:affinity] JSON records and the corresponding HTTP status/denial class.

    Please omit surrounding provider logs and any raw IDs, headers, metadata, request bodies, or credentials, then disable capture with ocx debug provider off.

    If you are testing a packaged release rather than current dev, first confirm that the build contains commit 98848575d; older 2.28.0 builds will not have this diagnostic. I will keep the issue open until that evidence identifies the exact combined affinity field boundary. We will not strip the full turn envelope or reset threads speculatively.

  14. jojodat commented on Aug 21, 2026

    @jojodat
    Author

    Diagnostic follow-up from the packaged OpenCodex 2.29.0 release (including #2196 / 98848575d) with Codex CLI 0.147.0.

    Result summary

    The behavior is client-path dependent:

    • Exact CLI control succeeds. I ran two independent persisted tasks with exact account-qualified selectors. In each, an exact main/gpt-5.6-sol turn succeeded and the same task then succeeded as exact k12lab/gpt-5.6-sol (HTTP 200 -> 200). In the stronger control, the task completed two Plus turns before the K12 switch. Both account-qualified records had fixedAccount=true, and there was no account retry, fallback, denial, or needs-reauth.
    • Codex App still reproduces the failure. In a fresh App task, I selected Plus with Use this account next, completed one successful turn, selected the second K12 account the same way, and sent the next turn in that same task. After the initial attempt plus five automatic reconnects, the App ended with HTTP 403 and detail.code=codex_workspace_access_denied.
    • The normal App catalog on this installation exposes generic model names rather than duplicate account-qualified main/... and k12lab/... entries. Therefore the App reproduction is a manual account selection in Pool mode, not an exact selector: the diagnostic correctly reports fixedAccount=false. I did not modify the normal catalog merely to manufacture an exact App selector.

    This test environment has two independently authenticated K12 accounts in the OpenCodex pool. k12lab is only an arbitrary local namespace for the second K12 account; no account identity is included below.

    Requested same-process App capture

    Plus success (status=200, denial none):

    {"sequence":32,"authKind":"main-pool","accountMode":"pool","fixedAccount":false,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":200,"inbound":[{"name":"originator","size":"1-31","tag":"d2bcfedffcd5"},{"name":"session-id","size":"32-127","tag":"8f3c3dd3ff9e"},{"name":"thread-id","size":"32-127","tag":"4b2a10bbe6db"},{"name":"x-client-request-id","size":"32-127","tag":"67fdcf93fdcb"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"86358030db12"},{"name":"x-codex-window-id","size":"32-127","tag":"9131b55c439d"}],"outbound":[{"name":"originator","size":"1-31","tag":"d2bcfedffcd5"},{"name":"session-id","size":"32-127","tag":"8f3c3dd3ff9e"},{"name":"thread-id","size":"32-127","tag":"4b2a10bbe6db"},{"name":"x-client-request-id","size":"32-127","tag":"67fdcf93fdcb"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"86358030db12"},{"name":"x-codex-window-id","size":"32-127","tag":"9131b55c439d"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"afafabaa8fea","kind":"string"},{"name":"thread_id","size":"32-127","tag":"8699e7adcd30","kind":"string"}],"unknownFieldCount":13},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"afafabaa8fea","kind":"string"},{"name":"thread_id","size":"32-127","tag":"8699e7adcd30","kind":"string"}],"unknownFieldCount":13},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}

    First K12 denial for the same App task (status=403, denial class workspace from detail.code):

    {"sequence":36,"authKind":"pool","accountMode":"pool","fixedAccount":false,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":403,"inbound":[{"name":"originator","size":"1-31","tag":"d2bcfedffcd5"},{"name":"session-id","size":"32-127","tag":"8f3c3dd3ff9e"},{"name":"thread-id","size":"32-127","tag":"4b2a10bbe6db"},{"name":"x-client-request-id","size":"32-127","tag":"67fdcf93fdcb"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"a259281b6e59"},{"name":"x-codex-window-id","size":"32-127","tag":"9131b55c439d"}],"outbound":[{"name":"originator","size":"1-31","tag":"d2bcfedffcd5"},{"name":"session-id","size":"32-127","tag":"8f3c3dd3ff9e"},{"name":"thread-id","size":"32-127","tag":"4b2a10bbe6db"},{"name":"x-client-request-id","size":"32-127","tag":"67fdcf93fdcb"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"a259281b6e59"},{"name":"x-codex-window-id","size":"32-127","tag":"9131b55c439d"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"afafabaa8fea","kind":"string"},{"name":"thread_id","size":"32-127","tag":"8699e7adcd30","kind":"string"}],"unknownFieldCount":13},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"afafabaa8fea","kind":"string"},{"name":"thread_id","size":"32-127","tag":"8699e7adcd30","kind":"string"}],"unknownFieldCount":13},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}

    What the capture establishes

    • This is a real App Plus-to-K12 transition: authKind changes from main-pool to pool.
    • The process-local equality tags for outer session-id, thread-id, x-client-request-id, and x-codex-window-id are identical across the credential/account change.
    • The known session_id and thread_id fields inside turn metadata are also identical.
    • x-codex-turn-metadata changes between turns, as expected, but inbound and outbound tags remain identical within each request. No x-codex-turn-state was present for this specific App pair.
    • The App UI showed five reconnects after the initial K12 attempt. The request history correspondingly contains six Sol 403 records with this same stable affinity cluster. Each Sol attempt was accompanied by a Luna 403 request forming a second stable affinity cluster, for twelve 403 diagnostic records total.
    • Because this App path was not fixed-account routing, OpenCodex used both K12 pool accounts across those attempts despite the manual Use this account next selection. It did not fall back to Plus. The selected account remained the second K12 account afterward, and neither K12 credential was marked needs-reauth.

    This narrows the remaining defect substantially: OpenCodex 2.29.0 can switch the same persisted task from exact Plus to exact K12 through the CLI, but the Codex App's manual-account/Pool path retains the App task affinity values and receives repeatable workspace denials. It also retries the denied user turn and its associated helper request across the K12 pool rather than failing once with a clear task/account incompatibility.

    If it would help distinguish App metadata from Pool routing, I can temporarily expose exact account-qualified entries in the App catalog and run one additional anonymized Plus-to-K12 App test.

    No raw account, thread/session, request, network, credential, metadata, or reset identifiers are included, and no further private data should be necessary.

  15. Ingwannu commented on Aug 21, 2026

    @Ingwannu
    Owner

    Thank you — this capture proves one additional local routing gap, but it still does not justify stripping the App affinity envelope.

    I rechecked current dev@236e03342:

    • resolveCodexAuthContext() derives Pool thread affinity only from x-codex-parent-thread-id.
    • Neither App record contains that header. The stable session-id, thread-id, and turn-metadata ids are forwarded upstream and used for log correlation, but they do not key the Codex account-affinity map.
    • PUT /api/codex-auth/active clears the existing affinity map and seeds the selected account. Subsequent App reconnects without x-codex-parent-thread-id are therefore unbound Pool selections, so a rotating strategy can visit both K12 credentials. This explains the observed cross-account retries; it does not explain the first workspace denial.
    • The workspace denial is classified as caller, so it correctly does not mark either credential needs-reauth. There is also no server-side 403 alternate-account retry in this path; the six Sol attempts are client reconnects arriving as new unbound requests.

    I will keep the two boundaries separate:

    1. Pool affinity should have a privacy-bounded fallback for Codex App requests that have a stable session-id / thread-id but no parent-thread header, so one App task does not rotate accounts across reconnects.
    2. We still need to determine whether the first 403 is caused by the App affinity envelope itself or by the manual Pool-selection path.

    Please run the additional exact account-qualified App test you offered, without changing any other setting: one successful Plus turn, then the same App task switched to an exact K12 catalog entry, in the same OCX process. Please provide only the two [ocx:codex:affinity] records and status/denial class.

    • If exact App routing still returns 403, the remaining defect is in the App task affinity envelope and we can narrow which field must be rejected/reset.
    • If exact App routing succeeds, the remaining defect is specifically the manual Pool-selection/fixed-account boundary.

    I will not delete all turn metadata, auto-reset the task, or treat workspace denial as a credential failure.

  16. jojodat commented on Aug 21, 2026

    @jojodat
    Author

    I ran the requested additional exact account-qualified Codex App test on packaged OpenCodex 2.29.0, in one OCX process and one fresh App task. The account-qualified picker was enabled before creating the task; no routing or account setting was changed between the two turns.

    Exact Plus success (main/gpt-5.6-sol, HTTP 200, denial none):

    {"sequence":63,"authKind":"main-pool","accountMode":"pool","fixedAccount":true,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":200,"inbound":[{"name":"originator","size":"1-31","tag":"d2bcfedffcd5"},{"name":"session-id","size":"32-127","tag":"f915cc669b49"},{"name":"thread-id","size":"32-127","tag":"09f45b6628bf"},{"name":"x-client-request-id","size":"32-127","tag":"f36ff1c9f792"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"596b496faf8d"},{"name":"x-codex-window-id","size":"32-127","tag":"8a376ad98b2c"}],"outbound":[{"name":"originator","size":"1-31","tag":"d2bcfedffcd5"},{"name":"session-id","size":"32-127","tag":"f915cc669b49"},{"name":"thread-id","size":"32-127","tag":"09f45b6628bf"},{"name":"x-client-request-id","size":"32-127","tag":"f36ff1c9f792"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"596b496faf8d"},{"name":"x-codex-window-id","size":"32-127","tag":"8a376ad98b2c"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"1f222d9daf06","kind":"string"},{"name":"thread_id","size":"32-127","tag":"8fc64b5b04e2","kind":"string"}],"unknownFieldCount":13},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"1f222d9daf06","kind":"string"},{"name":"thread_id","size":"32-127","tag":"8fc64b5b04e2","kind":"string"}],"unknownFieldCount":13},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}

    First exact K12 denial for the same App task (HTTP 403, denial class workspace from detail.code=codex_workspace_access_denied):

    {"sequence":77,"authKind":"pool","accountMode":"pool","fixedAccount":true,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":403,"inbound":[{"name":"originator","size":"1-31","tag":"d2bcfedffcd5"},{"name":"session-id","size":"32-127","tag":"f915cc669b49"},{"name":"thread-id","size":"32-127","tag":"09f45b6628bf"},{"name":"x-client-request-id","size":"32-127","tag":"f36ff1c9f792"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"089548332fe5"},{"name":"x-codex-window-id","size":"32-127","tag":"8a376ad98b2c"}],"outbound":[{"name":"originator","size":"1-31","tag":"d2bcfedffcd5"},{"name":"session-id","size":"32-127","tag":"f915cc669b49"},{"name":"thread-id","size":"32-127","tag":"09f45b6628bf"},{"name":"x-client-request-id","size":"32-127","tag":"f36ff1c9f792"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"089548332fe5"},{"name":"x-codex-window-id","size":"32-127","tag":"8a376ad98b2c"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"1f222d9daf06","kind":"string"},{"name":"thread_id","size":"32-127","tag":"8fc64b5b04e2","kind":"string"}],"unknownFieldCount":13},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"1f222d9daf06","kind":"string"},{"name":"thread_id","size":"32-127","tag":"8fc64b5b04e2","kind":"string"}],"unknownFieldCount":13},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}

    Result: exact App routing still returns 403. Both decisive records have fixedAccount=true. The equality tags for outer session-id, thread-id, x-client-request-id, and x-codex-window-id, plus the known inner session_id and thread_id, are identical across the account switch. Only x-codex-turn-metadata changes between turns, as expected; no x-codex-turn-state was present.

    This rules out the manual Pool-selection/fixedAccount=false path as a necessary condition for the denial and leaves the App task affinity envelope as the distinguishing boundary from the successful exact CLI control.

    The App also emitted interleaved generic Luna helper/reconnect requests; their records are intentionally omitted here because you requested only the two decisive affinity records. No raw identifiers or surrounding provider logs are included.

  17. Ingwannu commented on Aug 21, 2026

    @Ingwannu
    Owner

    Thank you. This exact account-qualified App control removes the manual Pool selection / fixedAccount=false path as a necessary condition. The first workspace denial still occurs with fixedAccount=true, while the same App task keeps the stable outer session/thread/request/window tags and stable inner session/thread tags across the Plus-to-K12 credential change.

    That proves the remaining first-403 boundary is in the Codex App task envelope, but it does not yet identify which field is incompatible. Equality tags prove stability, not causation, and x-codex-turn-metadata legitimately changes between turns. I will not strip the whole envelope or reset the task speculatively.

    The minimum next comparison is the successful exact CLI control under the same diagnostic build: in one OCX process and one persisted CLI task, capture the successful exact Plus turn followed by the successful exact K12 turn, and provide only those two [ocx:codex:affinity] records plus status/denial class. Comparing App versus CLI field presence and equality should identify the smallest candidate field set before any mutation probe.

    Separately, current dev@236e03342 has a confirmed local Pool-affinity gap: resolveCodexAuthContext() keys only x-codex-parent-thread-id, which these App requests omit. Therefore reconnects are unbound selections and can rotate across the two K12 accounts. I will handle a privacy-bounded App session/thread affinity fallback as a separate patch; it will stop cross-account reconnect rotation, but I will not claim that it fixes the first upstream 403.

  18. jojodat commented on Aug 21, 2026

    @jojodat
    Author

    Here is the requested successful exact CLI control from the same packaged OpenCodex 2.29.0 diagnostic build and the same OCX process as the later exact App capture. This was one persisted CLI task: an exact Plus turn followed by an exact K12 turn.

    Exact Plus CLI success (HTTP 200, denial none):

    {"sequence":24,"authKind":"main-pool","accountMode":"pool","fixedAccount":true,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":200,"inbound":[{"name":"originator","size":"1-31","tag":"1d4b8d3e5f14"},{"name":"session-id","size":"32-127","tag":"54ea7b534f56"},{"name":"thread-id","size":"32-127","tag":"13193ce0e2cd"},{"name":"x-client-request-id","size":"32-127","tag":"0d842ebd6063"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"128-511","tag":"de2f4eb3641d"},{"name":"x-codex-window-id","size":"32-127","tag":"4f78792b7096"}],"outbound":[{"name":"originator","size":"1-31","tag":"1d4b8d3e5f14"},{"name":"session-id","size":"32-127","tag":"54ea7b534f56"},{"name":"thread-id","size":"32-127","tag":"13193ce0e2cd"},{"name":"x-client-request-id","size":"32-127","tag":"0d842ebd6063"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"128-511","tag":"de2f4eb3641d"},{"name":"x-codex-window-id","size":"32-127","tag":"4f78792b7096"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"0fe317954c5b","kind":"string"},{"name":"thread_id","size":"32-127","tag":"b5df5d106ec4","kind":"string"}],"unknownFieldCount":6},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"0fe317954c5b","kind":"string"},{"name":"thread_id","size":"32-127","tag":"b5df5d106ec4","kind":"string"}],"unknownFieldCount":6},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}

    Exact K12 CLI success for the same persisted task (HTTP 200, denial none):

    {"sequence":26,"authKind":"pool","accountMode":"pool","fixedAccount":true,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":200,"inbound":[{"name":"originator","size":"1-31","tag":"1d4b8d3e5f14"},{"name":"session-id","size":"32-127","tag":"54ea7b534f56"},{"name":"thread-id","size":"32-127","tag":"13193ce0e2cd"},{"name":"x-client-request-id","size":"32-127","tag":"0d842ebd6063"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"128-511","tag":"52fcf69119cb"},{"name":"x-codex-window-id","size":"32-127","tag":"4f78792b7096"}],"outbound":[{"name":"originator","size":"1-31","tag":"1d4b8d3e5f14"},{"name":"session-id","size":"32-127","tag":"54ea7b534f56"},{"name":"thread-id","size":"32-127","tag":"13193ce0e2cd"},{"name":"x-client-request-id","size":"32-127","tag":"0d842ebd6063"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"128-511","tag":"52fcf69119cb"},{"name":"x-codex-window-id","size":"32-127","tag":"4f78792b7096"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"0fe317954c5b","kind":"string"},{"name":"thread_id","size":"32-127","tag":"b5df5d106ec4","kind":"string"}],"unknownFieldCount":6},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"0fe317954c5b","kind":"string"},{"name":"thread_id","size":"32-127","tag":"b5df5d106ec4","kind":"string"}],"unknownFieldCount":6},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}

    Both exact CLI records have fixedAccount=true, status 200, and no denial. The outer session/thread/client/window equality tags and the known inner session/thread tags remain stable across the CLI account switch; only x-codex-turn-metadata changes between turns, as expected.

    The visible structural difference from the exact App 200→403 pair is narrow: the header-name set is the same and x-codex-turn-state is absent in both clients, but CLI turn metadata is in size bucket 128-511 with unknownFieldCount=6, whereas the App pair is 512-2047 with unknownFieldCount=13. This narrows the candidate boundary to the additional App turn-metadata shape/content, without attributing causation to any individual field.

    No raw identifiers or surrounding provider logs are included.

  19. Ingwannu commented on Aug 21, 2026

    @Ingwannu
    Owner

    Thank you — this is the control we needed, and it narrows the boundary substantially.

    The same-process CLI Plus→K12 switch succeeds with fixedAccount=true on both turns, while the exact App pair fails only on the K12 turn. That rules out K12 entitlement, credential substitution, exact account selection, and the stable outer session/thread/window identifiers as sufficient causes. The larger App x-codex-turn-metadata blob is now the leading boundary, but the current comparison still has one important confound: the successful CLI is Codex CLI 0.147.0, while the App uses bundled 0.146.0-alpha.9.2.

    Current upstream Codex constructs x-codex-turn-metadata as one canonical payload containing core-owned fields plus app-server responsesapiClientMetadata; the latter is flattened into the same JSON after reserved-key filtering. Therefore unknownFieldCount=13 versus 6 can represent App-supplied metadata, a client-version schema difference, or both. It does not yet justify stripping the entire header or any guessed field.

    The next decisive control is the same binary, not another ordinary CLI run:

    1. Run the Codex binary bundled with OpenAI.CodexBeta 26.727.4816.0 in CLI mode, outside the App UI, through the same packaged OCX process.
    2. In one fresh persisted task, send exact main/gpt-5.6-sol, then exact k12lab/gpt-5.6-sol.
    3. Post only the two affinity JSON records and statuses, as before.

    If bundled-CLI succeeds while the App fails, we can isolate the seven additional App-supplied metadata fields. If bundled-CLI also fails, the boundary is the bundled 0.146.0-alpha.9.2 runtime/schema rather than the Desktop UI itself.

    If invoking the bundled binary directly is blocked by WindowsApps ACLs, please post the exact package executable path discovery result and do not change permissions. I will prepare the next privacy-bounded diagnostic to emit HMAC-tagged unknown field names/kinds rather than raw values; that lets us compare the additional field set without exposing identifiers, paths, workspace data, or metadata contents.

  20. jojodat commented on Aug 21, 2026

    @jojodat
    Author

    Bundled-App CLI control completed on the currently installed Desktop build.

    One important version caveat: the Desktop App has updated again since the earlier failing App pair. The currently bundled runtime reports codex-cli 0.148.0-alpha.21, not the previously observed 0.146.0-alpha.9.2. Therefore this is a control using the current bundled binary, not a byte-for-byte control of the earlier failing App runtime.

    I invoked that exact bundled binary outside the UI through the same running packaged OpenCodex 2.29.0 process. In one fresh persisted task:

    1. exact main/gpt-5.6-sol → HTTP 200, expected reply
    2. resume the same task with exact k12lab/gpt-5.6-sol → HTTP 200, expected reply

    The CLI explicitly reported that it was resuming the same session with a different model. These are the only two corresponding affinity records:

    {"sequence":97,"authKind":"main-pool","accountMode":"pool","fixedAccount":true,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":200,"inbound":[{"name":"originator","size":"1-31","tag":"1d4b8d3e5f14"},{"name":"session-id","size":"32-127","tag":"e669fdf53291"},{"name":"thread-id","size":"32-127","tag":"deb4f7cbe32a"},{"name":"x-client-request-id","size":"32-127","tag":"4b47d90c1dd5"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"a39ee8fa69e8"},{"name":"x-codex-window-id","size":"32-127","tag":"af365b7ced9e"}],"outbound":[{"name":"originator","size":"1-31","tag":"1d4b8d3e5f14"},{"name":"session-id","size":"32-127","tag":"e669fdf53291"},{"name":"thread-id","size":"32-127","tag":"deb4f7cbe32a"},{"name":"x-client-request-id","size":"32-127","tag":"4b47d90c1dd5"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"a39ee8fa69e8"},{"name":"x-codex-window-id","size":"32-127","tag":"af365b7ced9e"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"f9c8b8ddf704","kind":"string"},{"name":"thread_id","size":"32-127","tag":"ee0b275461d4","kind":"string"}],"unknownFieldCount":12},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"f9c8b8ddf704","kind":"string"},{"name":"thread_id","size":"32-127","tag":"ee0b275461d4","kind":"string"}],"unknownFieldCount":12},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}
    {"sequence":100,"authKind":"pool","accountMode":"pool","fixedAccount":true,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":200,"inbound":[{"name":"originator","size":"1-31","tag":"1d4b8d3e5f14"},{"name":"session-id","size":"32-127","tag":"e669fdf53291"},{"name":"thread-id","size":"32-127","tag":"deb4f7cbe32a"},{"name":"x-client-request-id","size":"32-127","tag":"4b47d90c1dd5"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"35425e8d54b8"},{"name":"x-codex-window-id","size":"32-127","tag":"af365b7ced9e"}],"outbound":[{"name":"originator","size":"1-31","tag":"1d4b8d3e5f14"},{"name":"session-id","size":"32-127","tag":"e669fdf53291"},{"name":"thread-id","size":"32-127","tag":"deb4f7cbe32a"},{"name":"x-client-request-id","size":"32-127","tag":"4b47d90c1dd5"},{"name":"x-codex-beta-features","size":"1-31","tag":"73834bcc80b8"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"35425e8d54b8"},{"name":"x-codex-window-id","size":"32-127","tag":"af365b7ced9e"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"f9c8b8ddf704","kind":"string"},{"name":"thread_id","size":"32-127","tag":"ee0b275461d4","kind":"string"}],"unknownFieldCount":12},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"9ec5bf39a3d7","kind":"string"},{"name":"session_id","size":"32-127","tag":"f9c8b8ddf704","kind":"string"},{"name":"thread_id","size":"32-127","tag":"ee0b275461d4","kind":"string"}],"unknownFieldCount":12},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}

    Within this successful bundled-CLI pair, the outer session/thread/client/window tags remain stable, the inner session/thread tags remain stable, and only the turn-metadata tag changes. Notably, both requests already have x-codex-turn-metadata size 512–2047 and unknownFieldCount 12. The failing Desktop-App K12 request had the same size bucket and count 13.

    So this control argues against metadata size alone being the boundary. It narrows the difference toward the identity/type/value of the App-only field(s), but a matching Desktop-UI retest on the current 0.148.0-alpha.21 build is still needed before treating this as a strict UI-vs-runtime isolation. The proposed privacy-bounded diagnostic with HMAC-tagged unknown field names/kinds should discriminate that cleanly.

    No additional private data should be necessary.

  21. Ingwannu commented on Aug 21, 2026

    @Ingwannu
    Owner

    The new bundled-CLI control changes the next step.

    0.148.0-alpha.21 succeeds on the exact Plus→K12 switch even with turn metadata in the same 512-2047 size bucket and 12 unknown fields. Therefore metadata size is not the cause, and the earlier Desktop failure's 13th field cannot be treated as causal without a same-version Desktop retest. Please rerun the exact account-qualified Plus→K12 transition in the current Desktop UI on this updated bundled runtime and report only the two bounded affinity records plus status/denial class. If it now succeeds, the first-403 symptom was upstream/client-version-specific; if it still fails, I will extend the opt-in diagnostic to emit process-local HMAC tags for unknown field names and coarse value kinds, never raw names or values.

    Separately, the capture already proves one OpenCodex defect that does not require another upstream guess: App requests without x-codex-parent-thread-id are unbound in Pool mode, so reconnects can rotate across K12 accounts. I will fix that independently and will not claim it fixes the first 403:

    • keep x-codex-parent-thread-id as the primary affinity key;
    • when it is absent, derive a process-local HMAC affinity key only from the pair of bounded session-id and thread-id headers;
    • never persist or log the raw identifiers or a durable hash;
    • carry the derived key through success/failure accounting so cooldown/failover clears the same binding;
    • keep exact account-qualified selectors fixed and outside Pool affinity;
    • add regressions proving same-session reconnects stay on one account, either missing component remains unbound, exact selectors do not mutate affinity, and transient failures clear only the derived binding.

    I will open that patch against dev for independent review. The first workspace denial remains open pending the current Desktop UI control.

  22. jojodat commented on Aug 21, 2026

    @jojodat
    Author

    Current Desktop-UI control completed. The failure still reproduces on the updated Desktop build whose bundled runtime reports codex-cli 0.148.0-alpha.21.

    In one fresh Desktop task:

    1. exact main/gpt-5.6-sol → HTTP 200, expected APP_MAIN_OK
    2. same task switched in the UI to exact k12lab/gpt-5.6-sol → HTTP 403
    3. the initial K12 request plus all five automatic reconnect attempts returned the same structured workspace denial (six 403 responses total)

    The denial class is workspace, from the already documented own-property detail.code = codex_workspace_access_denied. No raw response identifiers are included.

    As requested, here are only the successful Plus record and one representative exact-K12 denial record; the other five exact-K12 retries are identical in all bounded affinity fields:

    {"sequence":5,"authKind":"main-pool","accountMode":"pool","fixedAccount":true,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":200,"inbound":[{"name":"originator","size":"1-31","tag":"920c84f7054e"},{"name":"session-id","size":"32-127","tag":"8ffbff0ca501"},{"name":"thread-id","size":"32-127","tag":"002c750de0c5"},{"name":"x-client-request-id","size":"32-127","tag":"86c8b12bf13d"},{"name":"x-codex-beta-features","size":"1-31","tag":"fe15ab189c3c"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"d4a477a82f4b"},{"name":"x-codex-window-id","size":"32-127","tag":"6705e78e0f31"}],"outbound":[{"name":"originator","size":"1-31","tag":"920c84f7054e"},{"name":"session-id","size":"32-127","tag":"8ffbff0ca501"},{"name":"thread-id","size":"32-127","tag":"002c750de0c5"},{"name":"x-client-request-id","size":"32-127","tag":"86c8b12bf13d"},{"name":"x-codex-beta-features","size":"1-31","tag":"fe15ab189c3c"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"d4a477a82f4b"},{"name":"x-codex-window-id","size":"32-127","tag":"6705e78e0f31"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"cf461ea5c5fd","kind":"string"},{"name":"session_id","size":"32-127","tag":"2e0a3e4ce6eb","kind":"string"},{"name":"thread_id","size":"32-127","tag":"f454f61e33c0","kind":"string"}],"unknownFieldCount":13},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"cf461ea5c5fd","kind":"string"},{"name":"session_id","size":"32-127","tag":"2e0a3e4ce6eb","kind":"string"},{"name":"thread_id","size":"32-127","tag":"f454f61e33c0","kind":"string"}],"unknownFieldCount":13},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}
    {"sequence":19,"authKind":"pool","accountMode":"pool","fixedAccount":true,"credentialSubstituted":true,"accountGatedModel":false,"wireModelNormalized":false,"status":403,"inbound":[{"name":"originator","size":"1-31","tag":"920c84f7054e"},{"name":"session-id","size":"32-127","tag":"8ffbff0ca501"},{"name":"thread-id","size":"32-127","tag":"002c750de0c5"},{"name":"x-client-request-id","size":"32-127","tag":"86c8b12bf13d"},{"name":"x-codex-beta-features","size":"1-31","tag":"fe15ab189c3c"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"148fa50040ca"},{"name":"x-codex-window-id","size":"32-127","tag":"6705e78e0f31"}],"outbound":[{"name":"originator","size":"1-31","tag":"920c84f7054e"},{"name":"session-id","size":"32-127","tag":"8ffbff0ca501"},{"name":"thread-id","size":"32-127","tag":"002c750de0c5"},{"name":"x-client-request-id","size":"32-127","tag":"86c8b12bf13d"},{"name":"x-codex-beta-features","size":"1-31","tag":"fe15ab189c3c"},{"name":"x-codex-turn-metadata","size":"512-2047","tag":"148fa50040ca"},{"name":"x-codex-window-id","size":"32-127","tag":"6705e78e0f31"}],"inboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"cf461ea5c5fd","kind":"string"},{"name":"session_id","size":"32-127","tag":"2e0a3e4ce6eb","kind":"string"},{"name":"thread_id","size":"32-127","tag":"f454f61e33c0","kind":"string"}],"unknownFieldCount":13},"outboundTurnMetadata":{"shape":"object","knownFields":[{"name":"request_kind","size":"1-31","tag":"cf461ea5c5fd","kind":"string"},{"name":"session_id","size":"32-127","tag":"2e0a3e4ce6eb","kind":"string"},{"name":"thread_id","size":"32-127","tag":"f454f61e33c0","kind":"string"}],"unknownFieldCount":13},"inboundTurnState":{"shape":"absent"},"outboundTurnState":{"shape":"absent"}}

    The two exact account-qualified records have the same outer session/thread/client/window tags and the same inner session/thread tags. Both have x-codex-turn-metadata size 512–2047, unknownFieldCount 13, and no turn-state. Only the turn-metadata value tag changes across the turn.

    This is now a same-version isolation:

    • bundled 0.148.0-alpha.21 invoked outside the UI: exact Plus→K12 succeeds, unknownFieldCount 12
    • current Desktop UI using bundled 0.148.0-alpha.21: exact Plus→K12 still fails, unknownFieldCount 13

    Therefore the earlier failure is not explained by the old bundled runtime or metadata size alone. The next proposed privacy-bounded diagnostic for HMAC-tagged unknown field names and coarse value kinds should now isolate the App-only field difference without exposing raw names or values.

    No further private data should be necessary.

  23. Ingwannu commented on Aug 21, 2026

    @Ingwannu
    Owner

    I opened Draft PR #2296 for the independently proven reconnect-affinity defect.

    The patch keeps x-codex-parent-thread-id authoritative and, only when Desktop omits it, derives one opaque process-local HMAC affinity key from the complete bounded session-id + thread-id pair. Selection and outcome accounting now use that same key, while exact account-qualified selectors remain outside Pool affinity. Raw Desktop identifiers and durable hashes are not stored or logged.

    This should stop the six App reconnects from rotating across Pool accounts. It does not claim to fix the first exact K12 workspace denial shown by the current Desktop UI, and it does not delete turn metadata or reset the thread. That App-only 403 remains open; the next diagnostic must isolate the additional App metadata field by process-local HMAC-tagged field identity/kind, never raw names or values.

    Focused tests are 57/57, with typecheck, privacy scan, and diff checks passing. Because this touches account/auth affinity, #2296 remains Draft and I requested independent review from @lidge-jun before any merge.

  24. lidge-jun commented on Aug 22, 2026

    @lidge-jun
    Owner

    Reconnect rotation half is fixed: PR #2296 (with adversarial-review hardening) landed on dev via #2324 (merge commit ec3d032). Desktop reconnects now bind to one pool account across the preview and final auth paths. The separate UI-denial half reported in this issue remains open and needs its own fix.

  25. jojodat commented on Aug 27, 2026

    @jojodat
    Author

    Retested on the current setup with OpenCodex 2.34.0 and the current ChatGPT Desktop App; both have changed since the last failing reproduction. In the same existing Desktop task, exact account selection completed successfully in this sequence: Plus → K12 account 2 → K12 account 1. All three requests returned HTTP 200, stayed in the same conversation, and were verified in the OpenCodex routing log against the intended accounts. There was no 403, reconnect loop, or fallback.

    The previously reproducible Desktop-only cross-account failure therefore no longer reproduces in the current setup. This retest does not isolate what resolved the behavior; the change may be in OpenCodex, the Desktop App, the upstream service, or their interaction. From the reporter side this issue can be closed. Thank you for the fixes and investigation.

  26. lidge-jun commented on Aug 28, 2026

    @lidge-jun
    Owner

    Closing as completed.

    Reporter @jojodat confirmed (2026-08-27) that the Desktop cross-account sequence Plus → K12 account 2 → K12 account 1 now succeeds on OpenCodex 2.34.0: HTTP 200, same conversation, no 403 / reconnect loop / fallback.

    Work already on dev: #2055 (detail-scoped K12 classification), #2141 (K12 short-window quota), #2296 (Desktop reconnect affinity). The remaining UI-denial half no longer reproduces from the reporter's side.

    If it comes back on a later Desktop / OpenCodex pair, please open a new issue with a fresh log.

  27. jojodat commented on Aug 28, 2026

    @jojodat
    Author

    One additional observation for future reference; I am not requesting that the issue be reopened.

    The successful Desktop sequence reported above occurred in a task that was originally created as a K12-bound clean test. It therefore proves that a K12-origin task can temporarily use Plus and subsequently return to either K12 account, but it was not a strict test of a Plus-origin task switching to K12.

    I later resumed an older task created before the K12 accounts were added. In that same existing task:

    • both K12 accounts were attempted;
    • both repeatedly failed with HTTP 502 upstream_server_error, terminating mid-stream before usage was reported;
    • Plus then completed the same task successfully with HTTP 200.

    This does not appear to be a simple context-size or account-health issue: the working K12-origin task currently handles a larger input context, and both K12 accounts work normally there.

    The result suggests that persistent task/thread origin or historical continuation provenance may still determine cross-account compatibility: K12-origin → Plus → K12 works, while an older Plus-origin task → K12 may remain incompatible. The former 403 is now surfaced differently in this case, so I cannot claim the underlying cause is identical.

    Legacy-task compatibility is not important for my workflow, so leaving the issue closed is fine. This is only provided as potentially useful diagnostic context.

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

Metadata

Metadata

Assignees

Labels

account-poolOAuth, credentials, Codex pool, quota, failover, plansproviderProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reports

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions