Skip to content

fix(responses): rebind credential identity on every OAuth 429 rotation - #2807

Closed
lidge-jun wants to merge 3 commits into
devfrom
codex/oauth-failover-identity-v2
Closed

lidge-jun wants to merge 3 commits into
devfrom
codex/oauth-failover-identity-v2

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

Supersedes #2745 by carrying its two commits onto current dev and closing both blockers from @Ingwannu's security-boundary review.

Blocker 1 — the account-blind Copilot fallback

The defect was one ??:

refreshed.apiBaseUrl ?? getOAuthCredentialApiBaseUrl(route.providerName)

getOAuthCredentialApiBaseUrl is validateCopilotApiBaseUrl(getCredential(provider)?.apiBaseUrl) — it reads the active credential, with no account scoping. A generic 429 rotation never promotes the account it rotated to, so for a legacy account B carrying no allowlisted apiBaseUrl, that arm silently reached account A. B's bearer paired with A's origin — one account's token sent to another account's host.

copilotOriginForRefreshedCredential now resolves from the refreshed snapshot alone and otherwise fails closed to the canonical Copilot origin. It never consults, and never retains, another account's route:

refreshed snapshot for B resolved origin
own allowlisted origin that origin
legacy, no origin canonical — never A's
non-allowlisted origin canonical
empty string canonical

Blocker 2 — the source-text assertions

The review called them "not request-path behaviour". It was worse than that: one asserted the defect existed.

const refreshSites = coreSource.match(/refreshed\.apiBaseUrl \?\? getOAuthCredentialApiBaseUrl/g) ?? [];
expect(refreshSites.length).toBe(2);

That passes whether the assignment is reachable, whether the right snapshot arrives, and whether the resulting bearer/origin pair is correct — and fixing the bug would have broken it.

Replaced with a behavioural test driving the resolver across all four arms above, plus a topology guard that strips comments before scanning. The new helper's doc comment quotes the removed expression to explain why it was wrong, and the first version of the guard read that explanation as the defect.

Verification

bun x tsc --noEmit                        exit 0
tests/generic-oauth-failover.test.ts      19 pass / 0 fail
full suite (remote, pre-rebase head)      15358 pass / 0 fail, rc=0

Mutation-verified: restore the account-blind fallback and the guard fails (18 pass / 1 fail); with the fix, 19 / 0.

Still open from the original review — not claimed as closed

  • The applyFailoverSnapshot audit, where an undefined snapshot origin can leave the previous routed base URL on the provider object.
  • An executable A -> 429 -> B regression through the HTTP recovery path, asserting the second dispatch's bearer/origin pair and the B account/generation replay identity.

Both are worth doing. Neither is done here, and I would rather say so than let two closed blockers imply a clean sweep.

Integration-line disposition

No Go runtime counterpart. This is a credential-boundary change: MAINTAINERS.md requires explicit security review, and I authored it, so it needs a non-author maintainer.

Checklist

  • Both named blockers closed.
  • The new regression is behavioural and mutation-verified.
  • Rebased on current dev.
  • Exact-head required CI is green.
  • A non-author maintainer has reviewed the credential boundary.

Closes #2745.

Summary by CodeRabbit

  • Bug Fixes
    • Improved OAuth credential refresh handling to ensure requests use the correct Copilot service origin.
    • Fixed account failover behavior so rotated credentials, conversation state, and reasoning replay remain synchronized.
    • Prevented stale Cursor identity and conversation data from carrying over after credential rotation.
    • Added safer fallback behavior when a refreshed Copilot API URL is invalid.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 28, 2026 02:55
@github-actions

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 28, 2026
@coderabbitai

coderabbitai Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 33078cb9-e2cf-4ea2-88e8-51821b5490d5

📥 Commits

Reviewing files that changed from the base of the PR and between fe063d1 and 3de2eef.

📒 Files selected for processing (2)
  • src/server/responses/core.ts
  • tests/generic-oauth-failover.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

OAuth failover now rebinds credential identity, replay scope, and Cursor state after rotation. Copilot OAuth 401 refreshes validate the refreshed credential’s API origin and fall back to the canonical origin. Regression tests cover all rotation and refresh paths.

Changes

OAuth failover identity consistency

Layer / File(s) Summary
Account-specific Copilot origin resolution
src/server/responses/core.ts
Adds copilotOriginForRefreshedCredential, which validates refreshed.apiBaseUrl and falls back to GITHUB_COPILOT_DEFAULT_API_BASE. Both OAuth 401 refresh sites use this helper.
Failover snapshot and replay rebinding
src/server/responses/core.ts
applyFailoverSnapshot updates OAuth credential snapshots, clears Cursor identity and conversation state, and removes the Cursor continuation payload. Rotation sites bind reasoning replay scopes with the rotated credential snapshot. Duplicate Cursor clearing is removed from rotateRunTurnAdapterOnPreflight429.
Failover regression coverage
tests/generic-oauth-failover.test.ts
Tests verify credential identity updates, replay-scope wiring, account-specific Copilot origins, canonical fallback behavior, origin validation, and removal of the account-blind fallback.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🟠 High · up to 3de2e

OAuth 429 recovery can still retry a request with one account’s credential against another account’s approved Copilot origin when the rotated account lacks its own endpoint, creating a credential-boundary security risk. The PR is not merge-ready until the fallback is made account-scoped or otherwise explicitly accepted by the responsible security owner.

Suggested reviewers: ingwannu

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The implementation in src/server/responses/core.ts addresses the linked issue by rebinding OAuth snapshots, clearing Cursor state, re-scoping replay identity, and resolving refreshed Copilot origins… Replace the source-text assertions in tests/generic-oauth-failover.test.ts with behavioral tests that exercise allowlisted, legacy, non-allowlisted, and empty apiBaseUrl values, plus the comment-stripping topology guard. Retain regressi…
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the primary change: rebinding credential identity during OAuth 429 rotation.
Out of Scope Changes check ✅ Passed The changes in src/server/responses/core.ts and tests/generic-oauth-failover.test.ts remain within the linked issue scope. They modify OAuth failover identity binding, Copilot origin resolution, C…
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 2 files.
Full details: Linked Issues check

Explanation

The implementation in src/server/responses/core.ts addresses the linked issue by rebinding OAuth snapshots, clearing Cursor state, re-scoping replay identity, and resolving refreshed Copilot origins without using the active account. However, tests/generic-oauth-failover.test.ts uses structural source-text assertions instead of the behavioral origin tests required by the stated PR objective.

Resolution

Replace the source-text assertions in tests/generic-oauth-failover.test.ts with behavioral tests that exercise allowlisted, legacy, non-allowlisted, and empty apiBaseUrl values, plus the comment-stripping topology guard. Retain regression coverage for every OAuth rotation path and both 401 rebuild sites. [#2745]

Full details: Out of Scope Changes check

Explanation

The changes in src/server/responses/core.ts and tests/generic-oauth-failover.test.ts remain within the linked issue scope. They modify OAuth failover identity binding, Copilot origin resolution, Cursor state clearing, replay scoping, and related regression coverage.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/oauth-failover-identity-v2

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

❤️ Share

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

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 78 / 80

이 PR은 OAuth 계정 로테이션(특히 github-copilot)에서 토큰만 바꾸고 신원(identity)은 옛 계정을 가리키던 구멍을 한곳에서 막는다. 지금 dev(fe063d1)의 src/server/responses/core.ts에는 applyFailoverSnapshot이 아직 스냅샷의 apiKey/apiBaseUrl/project만 맞추고, sentOAuthSnapshot·replayOAuthCredentialSnapshot·Cursor conversation 정리는 호출 사이트마다 제각각이다. Copilot 쪽 강제 리프레시는 getOAuthCredentialApiBaseUrl(route.providerName)을 쓰는데, 이 함수는 지금 활성 계정의 origin을 읽는다. 레거시 계정 B에 allowlisted apiBaseUrl이 없으면 ?? 오른쪽이 계정 A의 host로 떨어져 B의 bearer + A의 origin이 된다. 이게 보안 경계 리뷰에서 막힌 핵심이다.

이 브랜치는 그걸 copilotOriginForRefreshedCredential로 바꿔, 방금 리프레시한 credential만 검증하고 없으면 GITHUB_COPILOT_DEFAULT_API_BASE로 fail-closed 한다. 동시에 applyFailoverSnapshot 안으로 identity 재바인딩(스냅샷 갱신, Cursor scope/conversation/_providerContinuation.cursor 폐기)을 옮겨서, 세 로테이션 사이트(runTurn / generic 429 / sidecar)가 다시 어긋나지 않게 한다. tests/generic-oauth-failover.test.ts는 헬퍼 소스 안에 잘못된 ?? getOAuthCredentialApiBaseUrl 패턴이 없고 identity 재바인딩이 들어갔는지를 고정한다. #2745를 현재 dev 위로 다시 올린 후속이고, Ingwannu 보안 리뷰 blocker 두 개를 닫는 목적이다.

라인 ~995-1015 (copilotOriginForRefreshedCredential) - 새 헬퍼가 계정-blind fallback을 끊고 기본 origin으로만 닫는지, 주석에 적힌 옛 ?? 표현이 실제 코드 경로에 다시 생기지 않는지 확인이 필요하다.
경로 applyFailoverSnapshot - sentOAuthSnapshot/replayOAuthCredentialSnapshot/Cursor id 정리가 헬퍼 안으로 모였는지, 예전처럼 한 사이트만 손으로 맞추는 분기가 남지 않았는지 본다.
라인 ~3581 / ~5103 근처 (Copilot resolveProviderTransport 호출) - getOAuthCredentialApiBaseUrl(...)이 copilotOriginForRefreshedCredential(refreshed)로 바뀌었는지, 초기 라우트 설정(아직 active credential을 읽는 자리)과 혼동하지 말 것.
경로 tests/generic-oauth-failover.test.ts - 헬퍼 본문 소스 스캔 테스트가 주석에 인용된 옛 표현을 false positive로 잡지 않도록 주석 strip 후 검사하는지 본다.
경로 PR #2745 - 이 PR이 supersede이므로 #2745는 열린 채로 두면 open PR 수가 부풀어 기여자를 헷갈리게 한다.

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

  • macos 체크가 아직 pending이다. test 1–4는 이미 통과했으니, macos만 그린이 되면 바로 머지할지.
  • #2745를 landed-via-maintainer로 닫을지(권장), 아니면 작성자가 스스로 닫게 둘지.
  • Copilot 기본 origin fail-closed가 지역 엔드포인트만 쓰는 계정에서 운영상 괜찮은지(의도된 안전 기본값인지).

너의 추천
macos가 그린이면 squash 머지하고, 곧바로 #2745에 Landed via #2807 at <commit> 댓글 + landed-via-maintainer 라벨 후 completed로 닫아라. 코드/테스트 방향은 현재 dev의 OAuth failover 방향과 맞다.

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

Rotating a credential without rotating the IDENTITY of that credential left the
two naming different accounts, and both consumers of that identity then
misbehaved.

applyFailoverSnapshot swapped the bearer, the Copilot origin, the Antigravity
project and the Kiro routing metadata, but never updated sentOAuthSnapshot or
replayOAuthCredentialSnapshot. sentOAuthSnapshot feeds the 401 forced refresh,
which refreshes the SNAPSHOT's account rather than whichever account is on the
route now - so a later 401 renewed the account the request had just rotated away
from. replayOAuthCredentialSnapshot scopes the reasoning replay cache, so a stale
value let continuation state minted under one account be addressed under another.

Both rebinds now live inside the helper. Three rotation sites already disagreed
about which of them to update, and the one that got it right (runTurn) got it
right by hand; a fourth site would have to remember too. In the helper it cannot
forget.

The other two sites are brought up to runTurn's standard: the HTTP 429 path had
no replay rebind at all (sealing the attempt identity records what happened, it
does not re-scope the cache), and the sidecar bound WITHOUT the credential
snapshot, which fails the OAuth replay key closed - safe-looking, but it leaves
the scope carrying no account identity to distinguish.

Both 401 rebuilds now prefer refreshed.apiBaseUrl over
getOAuthCredentialApiBaseUrl. The latter reads the ACTIVE credential, and generic
429 failover never promotes the active account, so after a rotation it still
named the rate-limited account - pairing a refreshed bearer with the wrong
account's origin.

On reachability, an independent audit was clear: on today's control flow a 401
cannot follow a 429 rotation back into the refresh path, because the HTTP 429
branch does not `continue recovery` and Copilot Responses models take a
passthrough return with no rotator at all. So this is latent identity drift, not
a live cross-account send. That missing continue is an ACCIDENTAL guard: adding
it - an obvious future improvement to 429 recovery - would activate the defect.
Which is the argument for fixing the identity first.

Three assertions added, each driven red on its own by reverting only its fix. The
replay-scope one is bounded to each rotation's own recovery call: an unbounded
forward search finds the NEXT site's bind and passes with this site's deleted,
and that weaker form was written first and survived the deletion.
…r too

The Cursor conversation and identity scope are credential-scoped exactly like the
snapshot and the replay scope, and they were cleared at only one of the three
rotation sites - runTurn, by hand. The same rotation that swapped the bearer
could therefore carry the previous account's conversation forward at the other
two.

For image turns that is not theoretical: src/images/loop.ts copies the
conversation id back onto the outer request, which is how it outlives the
rotation that should have ended it.

Moving the clear into the helper removes the per-site copy rather than adding two
more, and the test pins that there is exactly one - a duplicate is how these
three drifted apart in the first place.

862 Cursor tests across 52 files stay green.
…tation

Closes blocker 1 of the #2745 review. The fallback was
'refreshed.apiBaseUrl ?? getOAuthCredentialApiBaseUrl(provider)', and the second
arm is account-blind: getOAuthCredentialApiBaseUrl reads the ACTIVE credential,
and a generic 429 rotation never promotes the account it rotated to. So a legacy
account B carrying no allowlisted apiBaseUrl was rebuilt as B's bearer paired
with A's origin — one account's token sent to another account's host.

copilotOriginForRefreshedCredential resolves from the refreshed snapshot alone
and otherwise fails closed to the canonical Copilot origin. It never consults or
retains another account's route.

Behaviour across every arm:
  B has its own allowlisted origin  -> that origin
  B is legacy with no origin        -> canonical, never A's
  B has a non-allowlisted origin    -> canonical
  B origin is empty                 -> canonical

Also closes blocker 2 for this site. The old test asserted the source text
'refreshed.apiBaseUrl ?? getOAuthCredentialApiBaseUrl' appears exactly twice —
it asserted the DEFECT existed, and would have passed whether the assignment was
reachable or whether the resulting pair was correct. Replaced with a behavioural
test that drives the resolver, plus a topology guard that strips comments first
so the helper's own doc comment explaining the removed expression is not read as
the defect.

Mutation-verified: restoring the account-blind fallback fails the guard
(18 pass / 1 fail); with the fix, 19 pass / 0 fail. tsc exit 0.

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Requesting changes on exact head 1c61a7e8c. One credential/origin blocker remains in the generic OAuth 429 path.

applyFailoverSnapshot clones the existing route provider (including account A's baseUrl) and calls resolveProviderTransport(..., snapshot.apiBaseUrl) at src/server/responses/core.ts:2851-2858. When rotated account B has no stored apiBaseUrl, resolveGithubCopilotTransport falls through from the missing argument to validateCopilotApiBaseUrl(provider.baseUrl). Because that provider is the cloned A route, B's bearer is still paired with A's accepted Copilot origin. The new copilotOriginForRefreshedCredential helper closes the two 401 rebuild sites, but it is not used by this generic 429 snapshot path.

Please make the 429 rotation resolve exclusively from B's snapshot (for example, pass copilotOriginForRefreshedCredential(snapshot) or otherwise clear the previous account's origin before resolution). Add an executable A -> 429 -> B regression through the actual recovery/fetch path that asserts both the second dispatch bearer and origin. The current resolve = validate(...) ?? default unit merely reimplements the intended rule and cannot catch this reachable fallback through provider.baseUrl. Re-request review on the updated head.

harryzhou2000 pushed a commit to harryzhou2000/opencodex that referenced this pull request Aug 28, 2026
Four merged on the authorization (lidge-jun#2794, lidge-jun#2747, lidge-jun#2806, lidge-jun#2740), each already green
— it removed a process gate, not a verification one.

Two credential-path PRs were not merged despite the rights being available.
lidge-jun#2638's hygiene gate asks whether a human reviewed auth-context.ts, and
maintainer-sponsored IS that judgement, so applying it forges the gate.

lidge-jun#2807 is the interesting case: hygiene PASSES because core.ts is absent from
RESTRICTED_FILES, while CODEOWNERS:46 assigns it to me and MAINTAINERS.md:60
requires security review for credential handling. The gate is narrower than the
rule it encodes, and a passing check is not permission when the rule applies.

Left as a follow-up rather than fixed here: widening a security gate while
holding admin rights and an open PR the widening would block is the kind of
self-serving edit that needs its own review.
@lidge-jun

Copy link
Copy Markdown
Owner Author

Superseded by #2841, which carries this work forward on current dev.

This branch is CONFLICTING against dev and its diff overlaps src/server/responses/core.ts, which has moved considerably since. Rather than rebase a conflicting credential-path change, #2841 reimplements the fix on the current tree — and independent review of that PR turned up substantially more than this branch addressed:

  • The original defect held up: applyFailoverSnapshot cloned the failed account's provider and passed a bare snapshot.apiBaseUrl, so when the rotated-to account had no origin of its own, the resolver chain fell back to the previous account's origin — pairing one account's bearer with another's endpoint.
  • But that was only one of four sites. The initial Copilot binding and both 401 replay branches independently re-read getOAuthCredentialApiBaseUrl(...), which returns whichever account is active at that later instant rather than the account the snapshot belongs to. A concurrent login or account switch while a request was in flight reproduced account A's bearer being dispatched to account B's origin. fix(responses): bind a rotated OAuth bearer to its own Copilot origin #2841 makes all four snapshot-atomic.
  • The reachability claim also needed correcting: a Copilot credential with a missing origin is not an ordinary state, because exchangeCopilotToken has always resolved endpoints.api before persisting. The bare-apiBaseUrl fix is defense in depth for a malformed credential; the concurrent-switch bug is the ordinarily reachable one.

Closing in favour of #2841.

@lidge-jun lidge-jun closed this Aug 28, 2026
lidge-jun added a commit that referenced this pull request Aug 28, 2026
…#2841)

* fix(responses): bind a rotated OAuth bearer to its own Copilot origin

On a generic-OAuth 429 rotation, `applyFailoverSnapshot` clones the FAILED
account's provider and re-resolves Copilot transport with the rotated account's
`snapshot.apiBaseUrl`. When that account has no stored origin the bare
`undefined` reached the transport resolver, whose own fallback chain is

    validateCopilotApiBaseUrl(apiBaseUrl)          // undefined for account B
 ?? validateCopilotApiBaseUrl(provider.baseUrl)    // still account A's origin
 ?? GITHUB_COPILOT_DEFAULT_API_BASE

so the second step returned the cloned host and account B's bearer was sent to
account A's accepted origin.

An account legitimately has no stored origin whenever its login response carried
no `endpoints.api` — `saveCredential` only records `apiBaseUrl` when it is present
and validates — so this is reachable with ordinary credentials rather than crafted
ones. Both values were individually valid, which is why nothing caught it: the
defect is in the pairing, and the helper's own doc comment already claimed the
behavior the code did not implement.

Resolving the snapshot value before handing it over closes the fall-through. A
rotated account with its own regional origin keeps it; one without gets the
canonical default; a crafted non-Copilot origin is still refused.

The existing guard asserted the source contained `snapshot.apiBaseUrl`, which is
precisely the buggy expression — a text assertion that pinned the defect in place.
It now requires the resolved form, and four behavioral tests observe the pairing
directly, including one that reproduces the leak through the unresolved call so
the others cannot pass vacuously.

Verification:
  bun test tests/generic-oauth-failover.test.ts   19 pass 0 fail (was 18 pass 1 fail)
  bun x tsc --noEmit                              exit 0

Reimplements #2807 on current dev; that branch was 80 commits behind and
conflicting. Credit to the original investigation there.

* fix(responses): keep Copilot origin snapshot-atomic

Resolve every initial and refreshed Copilot transport origin from the same
OAuth access snapshot that supplied its bearer. Add account-switch regression
coverage for Chat, Responses, 401 replay, and 429 account failover.

* test(oauth): keep Copilot bearer fixtures privacy-safe

Build expected Authorization values from separate fixture parts so the
repository privacy scan does not mistake synthetic Copilot tokens for secrets.

---------

Co-authored-by: lidge-jun <lidge-jun@users.noreply.github.com>
@lidge-jun
lidge-jun deleted the codex/oauth-failover-identity-v2 branch September 5, 2026 09:03
tarunravi pushed a commit to tarunravi/opencodex that referenced this pull request Sep 14, 2026
Four merged on the authorization (lidge-jun#2794, lidge-jun#2747, lidge-jun#2806, lidge-jun#2740), each already green
— it removed a process gate, not a verification one.

Two credential-path PRs were not merged despite the rights being available.
lidge-jun#2638's hygiene gate asks whether a human reviewed auth-context.ts, and
maintainer-sponsored IS that judgement, so applying it forges the gate.

lidge-jun#2807 is the interesting case: hygiene PASSES because core.ts is absent from
RESTRICTED_FILES, while CODEOWNERS:46 assigns it to me and MAINTAINERS.md:60
requires security review for credential handling. The gate is narrower than the
rule it encodes, and a passing check is not permission when the rule applies.

Left as a follow-up rather than fixed here: widening a security gate while
holding admin rights and an open PR the widening would block is the kind of
self-serving edit that needs its own review.
tarunravi pushed a commit to tarunravi/opencodex that referenced this pull request Sep 14, 2026
…lidge-jun#2841)

* fix(responses): bind a rotated OAuth bearer to its own Copilot origin

On a generic-OAuth 429 rotation, `applyFailoverSnapshot` clones the FAILED
account's provider and re-resolves Copilot transport with the rotated account's
`snapshot.apiBaseUrl`. When that account has no stored origin the bare
`undefined` reached the transport resolver, whose own fallback chain is

    validateCopilotApiBaseUrl(apiBaseUrl)          // undefined for account B
 ?? validateCopilotApiBaseUrl(provider.baseUrl)    // still account A's origin
 ?? GITHUB_COPILOT_DEFAULT_API_BASE

so the second step returned the cloned host and account B's bearer was sent to
account A's accepted origin.

An account legitimately has no stored origin whenever its login response carried
no `endpoints.api` — `saveCredential` only records `apiBaseUrl` when it is present
and validates — so this is reachable with ordinary credentials rather than crafted
ones. Both values were individually valid, which is why nothing caught it: the
defect is in the pairing, and the helper's own doc comment already claimed the
behavior the code did not implement.

Resolving the snapshot value before handing it over closes the fall-through. A
rotated account with its own regional origin keeps it; one without gets the
canonical default; a crafted non-Copilot origin is still refused.

The existing guard asserted the source contained `snapshot.apiBaseUrl`, which is
precisely the buggy expression — a text assertion that pinned the defect in place.
It now requires the resolved form, and four behavioral tests observe the pairing
directly, including one that reproduces the leak through the unresolved call so
the others cannot pass vacuously.

Verification:
  bun test tests/generic-oauth-failover.test.ts   19 pass 0 fail (was 18 pass 1 fail)
  bun x tsc --noEmit                              exit 0

Reimplements lidge-jun#2807 on current dev; that branch was 80 commits behind and
conflicting. Credit to the original investigation there.

* fix(responses): keep Copilot origin snapshot-atomic

Resolve every initial and refreshed Copilot transport origin from the same
OAuth access snapshot that supplied its bearer. Add account-switch regression
coverage for Chat, Responses, 401 replay, and 429 account failover.

* test(oauth): keep Copilot bearer fixtures privacy-safe

Build expected Authorization values from separate fixture parts so the
repository privacy scan does not mistake synthetic Copilot tokens for secrets.

---------

Co-authored-by: lidge-jun <lidge-jun@users.noreply.github.com>
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
Four merged on the authorization (lidge-jun#2794, lidge-jun#2747, lidge-jun#2806, lidge-jun#2740), each already green
— it removed a process gate, not a verification one.

Two credential-path PRs were not merged despite the rights being available.
lidge-jun#2638's hygiene gate asks whether a human reviewed auth-context.ts, and
maintainer-sponsored IS that judgement, so applying it forges the gate.

lidge-jun#2807 is the interesting case: hygiene PASSES because core.ts is absent from
RESTRICTED_FILES, while CODEOWNERS:46 assigns it to me and MAINTAINERS.md:60
requires security review for credential handling. The gate is narrower than the
rule it encodes, and a passing check is not permission when the rule applies.

Left as a follow-up rather than fixed here: widening a security gate while
holding admin rights and an open PR the widening would block is the kind of
self-serving edit that needs its own review.
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…lidge-jun#2841)

* fix(responses): bind a rotated OAuth bearer to its own Copilot origin

On a generic-OAuth 429 rotation, `applyFailoverSnapshot` clones the FAILED
account's provider and re-resolves Copilot transport with the rotated account's
`snapshot.apiBaseUrl`. When that account has no stored origin the bare
`undefined` reached the transport resolver, whose own fallback chain is

    validateCopilotApiBaseUrl(apiBaseUrl)          // undefined for account B
 ?? validateCopilotApiBaseUrl(provider.baseUrl)    // still account A's origin
 ?? GITHUB_COPILOT_DEFAULT_API_BASE

so the second step returned the cloned host and account B's bearer was sent to
account A's accepted origin.

An account legitimately has no stored origin whenever its login response carried
no `endpoints.api` — `saveCredential` only records `apiBaseUrl` when it is present
and validates — so this is reachable with ordinary credentials rather than crafted
ones. Both values were individually valid, which is why nothing caught it: the
defect is in the pairing, and the helper's own doc comment already claimed the
behavior the code did not implement.

Resolving the snapshot value before handing it over closes the fall-through. A
rotated account with its own regional origin keeps it; one without gets the
canonical default; a crafted non-Copilot origin is still refused.

The existing guard asserted the source contained `snapshot.apiBaseUrl`, which is
precisely the buggy expression — a text assertion that pinned the defect in place.
It now requires the resolved form, and four behavioral tests observe the pairing
directly, including one that reproduces the leak through the unresolved call so
the others cannot pass vacuously.

Verification:
  bun test tests/generic-oauth-failover.test.ts   19 pass 0 fail (was 18 pass 1 fail)
  bun x tsc --noEmit                              exit 0

Reimplements lidge-jun#2807 on current dev; that branch was 80 commits behind and
conflicting. Credit to the original investigation there.

* fix(responses): keep Copilot origin snapshot-atomic

Resolve every initial and refreshed Copilot transport origin from the same
OAuth access snapshot that supplied its bearer. Add account-switch regression
coverage for Chat, Responses, 401 replay, and 429 account failover.

* test(oauth): keep Copilot bearer fixtures privacy-safe

Build expected Authorization values from separate fixture parts so the
repository privacy scan does not mistake synthetic Copilot tokens for secrets.

---------

Co-authored-by: lidge-jun <lidge-jun@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants