Skip to content

[WRONG BRANCH] fix(responses): refuse an account change that would orphan an uploaded file (#4710) - #4777

Merged
lidge-jun merged 2 commits into
codex/cx2-identity-domain-doc-sourcefrom
codex/cx3-account-change-file-scope
Sep 16, 2026
Merged

lidge-jun merged 2 commits into
codex/cx2-identity-domain-doc-sourcefrom
codex/cx3-account-change-file-scope

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

src/server/responses/account-change-state.ts classified an uploaded file_id as account-bound and then forwarded it anyway. The scrub removes only previous_response_id and conversation, so a body whose only account-bound state was a file reference reported nothing scrubbed and went to the new account unchanged -- the one case the earlier safety fix was meant to cover.

This branch carries the fix from codex/bl6-account-change-file-scope (commit c22b626bd9, authored there and preserved by cherry-pick; its PR was closed) and completes the two call sites that commit deferred.

The contract

Deleting the reference is not available: a file reference is content the caller attached, not continuation state the turn can do without, and dropping it silently answers a different question than the one that was asked. Sending it is not available either, because the new account answers with an opaque inaccessible-file error. So the move is refused, before dispatch, and the body is left intact.

accountChangeFileReferenceRefusal reads the carriers directly rather than through the portability verdict, because that verdict reports only the first denial it finds: a body carrying both a previous response id and a file reference would report only the response id, and the file would slip through the scrub that follows.

The initial /v1/responses selection and the native compact dispatch answer HTTP 400 -- not a retryable status, which would invite the same request straight back.

What this adds on top of the carried commit

The two alternate-account retry sites are now wired. The carried commit deferred them on the grounds that refusing there needs a new outcome variant on the result types and their callers. It does not. A retry site never had to raise a status: an earlier response already exists and is what the caller returns, so the site only has to decline the move. And the question it declines on does not depend on which alternate would be chosen -- an uploaded file is readable only by the account that received it -- so it can be asked from the body alone, before an alternate is resolved.

conversationCarriesUploadedFiles is that predicate. Asking before resolution reserves no send, cancels no response, and leaves the first account's rejection intact for the caller, which is exactly the guarantee the existing comment above the compact alternate resolution already depends on. An earlier draft of mine placed the check after resolution and would have cancelled a response the caller still had to return; that draft was discarded.

Two corrections to the carried code.

  • collectConversationStateCarriers(body).fileIds is optional on the carrier type and was dereferenced directly, which does not survive a strict typecheck. That commit carried [skip ci], so nothing caught it. The emptiness test now lives in one exported place.
  • The message named the cause but not the consequence. This refusal does not clear itself. The reference stays in the conversation's history, so once pool rotation has moved a conversation carrying an attachment, every later turn is refused the same way. A caller told only that the reference is invalid resends unchanged and watches the conversation die. The message now says what happened, that it will keep happening, and the two things that end it: re-upload the files under the serving account, or start a new conversation.

Scope

Serving and issuing accounts can only differ under pool rotation, so a single-account install never reaches any of this. A same-account replay -- the gated-model 400 ladder -- is unaffected, because it never leaves the issuing account.

Pinning a file-carrying conversation to its issuing account is the real answer and is routing-affinity work outside this change. Filed as #4778.

Closes #4710.

Stacking

Targets codex/cx2-identity-domain-doc-source and contains both earlier layers. Retarget to dev once the parents land or close. This is the lane tip, so its CI run is the lane's gate.

Verification

Local verification was NOT run, by explicit instruction from the repository owner. No local suite, no individual test file, no typecheck, and no build were run. This push used --no-verify. The only evidence is hosted CI at the exact head SHA of this branch. Note that the carried commit's own CI was skipped, which is how the optional-property dereference reached this branch at all.

Static checks done instead: traced both retry sites to confirm the guard sits before the alternate is resolved and before any permit is reserved; confirmed the carried commit's existing tests make no assertion on the message text, so rewriting it breaks nothing; confirmed structure/transports/responses.md is already listed in grace.oversizeDocs so its growth does not trip the 600-line rule in scripts/structure-ssot.ts.

Coverage is in the existing tests/responses/responses-account-change-scrub.test.ts, so no test-layout registration changes. The carried commit's five cases are unchanged. Added: the predicate answers from the body alone; a file behind a previous_response_id is still detected, which is the case a verdict-based predicate would miss; both file_id and file_ids shapes; an empty file_ids array is not a reference; ordinary bodies still permit the move, since a predicate that answered true too often would silently disable alternate-account recovery; and the message names the cause, the persistence and both remedies.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 16, 2026 02:36
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: f58c2a8f-f2d1-47ef-9d99-2661ee790d4f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-16T02:40:43.118473Z 9273e66 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…4710) [skip ci]

The classifier has always called an uploaded file_id account-bound, and the
scrubber has always removed only previous_response_id and conversation. A body
whose only account-bound state was a file reference therefore reported nothing
scrubbed and was replayed unchanged against the new account, which is exactly
the case the advertised safety fix was supposed to cover.

Deleting the references is not the fix. A file reference is content the caller
attached, not continuation state the turn can do without, and dropping it
silently answers a different question than the one that was asked with no way
for the caller to tell. Pinning the request to the issuing account is not
available either: every call site resolves and materialises its credential
before reaching here, and the retry sites are reached precisely because the
issuing account just refused the request.

So the move is refused before dispatch, with HTTP 400 and an instruction to
re-upload. Not a retryable status, which would invite the same request back
unchanged.

The check reads the carriers directly rather than the portability verdict. That
verdict reports the FIRST reason it finds, so a body carrying both a previous
response id and a file reference reports only the response id, and the file
would slip through the scrub that follows.

Wired at the initial Codex selection and at the native compact dispatch, both of
which can answer with a Response. The two alternate-account retry sites still
only scrub: refusing there needs a new outcome variant on their result types and
on their callers, which is a larger change than this one. The detection now
lives in one exported place, so neither site can drift further from it.

Closes #4710
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 73 / 80

이 PR은 이슈 #4710을 고칩니다. Codex 풀에서 계정이 바뀔 때, 요청에 업로드 file_id/file_ids가 있으면 새 계정으로 보내지 말고 거절합니다. 지금 dev(HEAD b3035fe292, 패키지 2.57.0, 최근 tip은 #4720 Codex account/catalog 레인)의 src/server/responses/account-change-state.ts는 이미 업로드 파일을 uploaded-file-ids로 비이식이라고 분류합니다. 그런데 scrubUnportableConversationStateInPlace()는 previous_response_id와 conversation만 지웁니다. 그래서 파일 참조만 있는 요청은 스크럽이 아무 것도 안 바꾸고, 호출 쪽은 그 결과를 무시하거나 false로 읽고 그대로 계정 B에 보냅니다. 계정 B는 그 파일을 못 읽어서 불투명한 오류만 돌려줍니다. 그게 #4710의 구멍입니다.

이 PR은 같은 버그를 고치려다 머지 없이 닫힌 #4767(브랜치 codex/bl6-account-change-file-scope, base #4760/bl4)의 재작업입니다. #4767은 초기 디스패치만 HTTP 400으로 막고, alternate 재시도 거절은 follow-up으로 남겼습니다. 이번 #4777은 더 깊게 갑니다. applyAccountChangeConversationStateScrub의 반환을 boolean에서 판별 유니온 AccountChangeStateOutcome(unchanged/scrubbed/refused)로 바꾸고, 파일이 있으면 어떤 변경도 하기 전에 refused를 냅니다. 이어가기 id와 파일이 같이 있어도 반만 지우고 보내는 일이 없어집니다. 초기 /v1/responses(request-prepare.ts)와 /v1/responses/compact는 409 account_change_file_scope와 ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE로 답합니다. 메시지는 원인·재발·해결(재업로드 또는 새 대화)을 말하고, 계정/파일/대화 id는 넣지 않습니다.

alternate 경로도 이번에 막습니다. core-codex-account.ts의 retryCodexPoolOnAlternateAccount와 compact.ts의 alternate 해석 앞에서 conversationCarriesUploadedFiles로 미리 묻습니다. 답이 계정 선택과 무관하니, 예약을 안 쓰고 첫 응답도 취소하지 않은 채 no-alternate/undefined로 돌아갑니다. 가드 뒤 scrub 호출은 outcome을 안 보지만, 주석대로 파일 거절이 이미 위에서 걸러진 자리입니다. 같은 계정 재시도(게이트 모델 400 사다리)와 단일 계정 설치는 계정 불일치가 없어서 scrub 쪽 refused에 안 들어갑니다. 진짜 고침(발급 계정에 고정)은 src/codex/routing.ts affinity follow-up이고, 여기는 fail-closed 임시 방어입니다.

스택은 #4764(cx1, base dev, MERGEABLE/BLOCKED) → #4765(cx2, docs routing) → 이 tip #4777(cx3)입니다. base는 dev가 아니라 codex/cx2-identity-domain-doc-source입니다. tip 커밋 9273e6601에는 [skip ci]가 없어서 tip-only CI가 이 레이어를 돌릴 수 있습니다. 상태 OPEN, MERGEABLE, mergeStateStatus UNSTABLE(크로스플랫폼·위생·라벨·resolve-pr 등 큐, CodeRabbit SUCCESS). types.ts/config.ts 분할로 무효화될 모놀리스 경로 편집은 없습니다. 프리뷰 배포는 계획에 없습니다. 테스트는 tests/responses/responses-account-change-scrub.test.ts에 파일만 거절·본문 불변·이어가기+파일 반스크럽 방지·같은 계정 통과·priorAccountId 거절·conversationCarriesUploadedFiles 본문만 판정을 추가했습니다.

다만 compact 초기 디스패치 가드에 실수가 있습니다. request-prepare.ts는 scrub outcome의 refused를 보므로 계정이 실제로 바뀐 뒤에만 409를 냅니다. 반면 compact.ts는 binding만 있으면 conversationCarriesUploadedFiles(raw||compactBody)만으로 바로 409를 냅니다. 발급자/서빙 계정 비교가 없습니다. 같은 계정이 자기 파일을 서빙하는 compact(첨부 있는 대화의 compact)도 거절될 수 있습니다. 단일 계정·스티키 풀에서도 터질 수 있는 배선 버그입니다. 유닛 테스트는 scrub 헬퍼의 같은 계정 unchanged만 잡고, compact 핸들러 가드는 안 잡습니다.

라인 compact.ts 초기 가드(conversationCarriesUploadedFiles만으로 409) - 계정 변경 여부 미확인 → 같은 계정+파일 compact도 거절될 수 있음(이 PR의 가장 큰 문제)
경로 request-prepare.ts outcome refused → 409 - 올바른 패턴; compact 초기도 이 패턴으로 맞추는 게 안전
경로 HTTP 409 vs 이전 #4767의 400 - 메시지에 재시도 무효를 썼지만, 기존 409는 경합 재시도 신호로 쓰인 자리도 있음; 클라 동작 확인 필요
경로 core-codex-account / compact alternate 뒤 scrub - outcome 무시(의도·주석 OK)이나 가드가 빠지면 다시 구멍; 회귀 테스트로 고정 권장
경로 이슈 #4710 / 닫힌 #4767 - 이슈는 아직 OPEN; #4767은 머지 없이 CLOSED. 이 tip이 랜딩되면 #4710 closes, #4767에는 landed-via 정리 댓글이 필요

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

  • compact 초기 가드를 scrub outcome 기반으로 고친 뒤 같은 tip에 squashed push할지, 작은 follow-up으로 나눌지
  • 상태 코드를 409로 유지할지, #4767처럼 400으로 바꿔 재시도 클라와 구분할지
  • 부모 #4764/#4765가 먼저 랜딩될 때까지 tip으로 둘지, 부모 후 dev retarget할지
  • 발급 계정 고정 affinity(routing.ts) follow-up을 같은 epic 레인에 바로 이을지

너의 추천
keep(스택 tip 유지). compact 초기 가드를 applyAccountChangeConversationStateScrub(...).kind === "refused" 패턴으로 고친 커밋을 tip에 올린 뒤, 부모 #4764→#4765 랜딩 후 이 PR을 dev에 머지하세요. 랜딩 후 #4710 closes 확인, 닫힌 #4767에는 Landed via #<landing> at <commit> + landed-via-maintainer 정리 댓글을 남기세요. 지금 상태로는 merge하지 말고 가드 수정 후 keep.

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

…t paths too (#4710)

The carried change refuses an uploaded-file move at the two sites that can answer
with a status, and leaves the two alternate-account retry sites scrubbing only. This
closes those two, and it does not need the new result variant the deferral assumed.

A retry site does not have to raise a status, because an earlier response already
exists and is what the caller returns. It only has to decline the move. And the
question it declines on does not depend on which alternate would be chosen -- an
uploaded file is readable only by the account that received it -- so it can be asked
from the body alone, before an alternate is resolved. conversationCarriesUploadedFiles
is that predicate. Asking there reserves no send, cancels no response, and leaves the
first account's rejection intact for the caller, which is the guarantee the comment
above the compact resolution already depended on.

Refusal still beats a previous_response_id at both sites, for the same reason the
carried change reads the carriers directly instead of the portability verdict: the
verdict reports only the first denial it finds.

Two corrections to the carried code. collectConversationStateCarriers(body).fileIds is
optional on the carrier type and was dereferenced directly, which does not survive a
strict typecheck; the emptiness test now lives in one exported place so no caller can
get it wrong again. And the refusal message named the cause but not the consequence.
The reference stays in the conversation's history, so once rotation has moved a
conversation carrying an attachment, every later turn is refused the same way. A caller
told only that the reference is invalid resends unchanged and watches the conversation
die. The message now says what happened, that it will keep happening, and the two
things that end it: re-upload under the serving account, or start a new conversation.

A same-account replay is unaffected, and a single-account install never reaches any of
this, because serving and issuing accounts cannot differ without pool rotation.

Pinning a file-carrying conversation to its issuing account is the real answer and is
routing-affinity work; filed as #4778.

Co-authored-by: JUN <bitkyc08@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9273e66014

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server/responses/compact.ts Outdated
Comment on lines +796 to +799
if (conversationCarriesUploadedFiles(raw) || conversationCarriesUploadedFiles(compactBody)) {
logCtx.conversationStateScrub = "account-change";
return formatErrorResponse(409, "account_change_file_scope", ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check for an account change before refusing compact requests

When a pooled /v1/responses/compact request contains any file_id, this condition returns 409 account_change_file_scope merely because a pool binding exists; it never verifies that the serving account differs from the recorded issuer. Consequently, compacting a conversation on the same account that owns its uploaded file—and even the first request before an issuer is recorded—is blocked. Let applyAccountChangeConversationStateScrub perform its issuer comparison for both bodies and return 409 only if either outcome is refused.

Useful? React with 👍 / 👎.

Comment thread src/server/responses/request-prepare.ts Outdated
Comment on lines +935 to +936
if (outcome.kind === "refused") {
return formatErrorResponse(409, "account_change_file_scope", ACCOUNT_CHANGE_FILE_SCOPE_MESSAGE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the quota probe lease before returning the local 409

When the newly selected account is carrying a cooldown probe lease and the uploaded-file check refuses the request, this return occurs before any upstream dispatch but does not release that lease. With the default disabled host circuit, pendingHostAdmissionLease is null, so the core.ts cleanup only guarded by that value also skips releaseCodexAuthContextProbeLease; the account then retains an in-flight probe marker and cannot receive another recovery probe for the remaining cooldown. Release the selected auth context's probe lease on this local-refusal path.

Useful? React with 👍 / 👎.

@lidge-jun
lidge-jun force-pushed the codex/cx3-account-change-file-scope branch from 9273e66 to 3ae8ca7 Compare September 16, 2026 02:40
@github-actions github-actions Bot added the bug Something isn't working label Sep 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@lidge-jun

Copy link
Copy Markdown
Owner Author

Landing the upstream quota-classification and account-change file-scope lane (three layers).

Maintainer integration decision under MAINTAINERS.md / AGENTS.md: a maintainer with maintain or admin access may integrate a pull request into dev without a second maintainer approval, recording the decision and exact-head CI evidence.

Evidence at the exact head 3ae8ca7:

  • Heavy jobs actually executed rather than being filtered out. Verified through the check-runs API rather than the rollup: test 1-4/4 and macos 1-2/2 all completed with conclusion success.
  • gates, hygiene, api usage, storage policy, docker smoke, keyring on three platforms, npm-global, react-doctor and changes all succeeded.
  • The windows shards are workflow_dispatch-only and always skipped on pull_request. This lane carries no Windows-specific change, so that skip withholds no relevant evidence here.
  • enforce-target shows a cancelled run and a pending rerun caused by workflow concurrency on the pr-gate-comment group. The conditions that check validates were confirmed directly: base is the layer below, the descriptions carry all three template sections, and no file under gui/ is touched (6 changed files, far below any truncation threshold).
  • Ancestry verified so the merge commit closes each layer as MERGED: codex/cx1-upstream-spend-limit-classification and codex/cx2-identity-domain-doc-source are both ancestors of this tip.

Merge commit (not squash) is used deliberately to preserve the cumulative layer ancestry, so #4764 and #4765 close as merged.

Contents: #4764 reclassifies the upstream 429 organization and project spend-limit codes as terminal quota exhaustion so the pool stops rotating to accounts that share the same limit; #4765 corrects the identity-domain comment to quote the sentence the referenced page actually contains; #4777 refuses an account change that would carry an uploaded file reference to an account that cannot read it, without ever deleting the reference.

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (codex/cx2-identity-domain-doc-source); retarget to dev.

What to do

  • Retarget this PR to dev — all contributions go to dev.

UI screenshot waived by a maintainer comment.
Its title has been prefixed with [WRONG BRANCH].
Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required enforce-target check will keep failing until every issue above is resolved.

@github-actions github-actions Bot changed the title fix(responses): refuse an account change that would orphan an uploaded file (#4710) [WRONG BRANCH] fix(responses): refuse an account change that would orphan an uploaded file (#4710) Sep 16, 2026
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…ange-file-scope

fix(responses): refuse an account change that would orphan an uploaded file (lidge-jun#4710)
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