Skip to content

fix(oauth): clear the Anthropic refresh intent after a transient failure - #3111

Merged
lidge-jun merged 8 commits into
devfrom
codex/2989-anthropic-refresh-intent
Sep 1, 2026
Merged

fix(oauth): clear the Anthropic refresh intent after a transient failure#3111
lidge-jun merged 8 commits into
devfrom
codex/2989-anthropic-refresh-intent

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

Carries #2989 (author @luvs01, maintainer-sponsored) rebased onto current dev. All eight commits are the author's and needed no changes.

refreshAnthropicAccountWithLock writes a durable refresh intent before dispatching, then rethrows non-terminal errors without clearing it (src/oauth/index.ts:681-702 on dev). terminal() covers only 400/401 plus grant/revoke codes, so a plain 503 left the marker on disk. The next attempt saw a same-generation intent and treated the token as possibly consumed — OAuthLoginRequiredError, manual reauth, for one transient upstream failure.

The intent is now cleared when the refresh either never reached the provider or was definitively answered, and kept when the outcome is genuinely uncertain. Cleanup is durable and resumable, so a failed unlink is retried rather than masking a credential that did land.

Why the existing test did not catch this

Anthropic transient failures do not mark needsReauth (tests/oauth-refresh.test.ts:500-508 on dev) asserts only the first throw. It never re-enters refreshAnthropicAccountWithLock, and re-entry is where the stale intent does its damage — so it stays green on the broken path. The added tests drive the second attempt.

Verification

bun test tests/oauth-refresh.test.ts   -> 55 pass / 0 fail / 264 expect()
bun x tsc --noEmit                     -> exit 0

Both directions of the new condition are mutation-checked, each restored:

mutation result
always clear the intent (if (attemptIntent)) 53 pass / 2 failan Anthropic refresh with an uncertain post-dispatch outcome preserves its intent and the persistence-failure replay guard
never clear it (if (false && ...)) 47 pass / 8 fail — including a definitive transient Anthropic HTTP failure leaves the account refreshable and the three cleanup-retry tests

Two mutations, two disjoint failure sets: over-clearing risks replaying a token that may already have rotated, under-clearing is the reported outage. Both sides are guarded, which is the property this change needs to have.

Security note

This touches credential refresh and OAuth state, so it needs the security review MAINTAINERS.md requires. The PR carries maintainer-sponsored from #2989. Nothing here logs or serializes a token; definitivelyAnswered reads only AnthropicTokenError.httpStatus, which postJson attaches on an explicit non-success response.

Checklist

  • Focused tests for the changed subsystem pass
  • bun x tsc --noEmit clean
  • Regression tests present and mutation-verified in both directions
  • Security-sensitive surface flagged for review
  • No docs-site change needed (internal refresh recovery)

Triaged in the 2026-08-31 non-priority-70 bug round; supersedes #2989 by rebase only.

Summary by CodeRabbit

  • Bug Fixes
    • Improved OAuth token refresh reliability during temporary network errors, interrupted requests, and storage contention.
    • Prevented unsafe duplicate refresh attempts and preserved valid account sessions when recovery is possible.
    • Added safer handling for expired or definitively rejected credentials, prompting reauthentication when required.
    • Improved recovery after interrupted cleanup and concurrent refresh activity.
    • Enhanced token rotation and insufficient-permission handling for supported providers.

A timeout, lost response, unreadable body, or credential-store failure can
happen after Anthropic consumed and rotated a refresh token. Clearing the
durable intent lets the next attempt replay the old token, risking reuse
handling and forced reauthentication. The store's uncertain flag does not
cover these request outcomes.

Track the pre-dispatch boundary explicitly. Clear a non-terminal intent only
before dispatch or after the adapter reports an explicit non-success HTTP
response; retain it after all other post-dispatch outcomes and after provider
success followed by persistence failure.

Intent cleanup is secondary to the refresh result. A failed unlink now leaves
the replay guard in place without replacing the original provider error,
pre-dispatch abort, terminal login result, or successfully persisted
credential.

Cover definite 503 retry, uncertain post-dispatch failure, pre-dispatch abort,
cleanup failure, post-provider persistence failure, and post-persist cleanup
failure.

(cherry picked from commit 9b55278)
Persist an attempt-scoped cleanup-pending marker before returning a definitive rejection or pre-dispatch abort. Serialize all refresh-intent mutations with the existing SQLite config transaction, refuse guard overwrites, and retry only the exact safe cleanup before provider redispatch. Preserve ordinary intents for timeouts and post-provider persistence uncertainty.

(cherry picked from commit 97a547c)
…edential

When Anthropic adopts a newer Claude Code credential, mergeAccountCredential
persists it before the observed refresh intent is cleaned up. Both exact-match
and generation-based cleanup rethrow non-ENOENT unlink errors, so a locked or
read-only intent file turned a successful adoption into a rejected refresh even
though the credential was already durable on disk.

Those two post-commit sites now use the existing best-effort helper, which logs
and preserves the replay guard instead of throwing. The two remaining throwing
calls are unchanged: their return values gate stale-flight handling before a
refresh is dispatched, so their failures must still surface.

The refresh-intent file also carried its own snapshot type and identity check
that duplicated the OAuth file lock's snapshot/sameSnapshot pair, differing only
in a field name. Both are compare-and-swap decisions on secret-adjacent files,
so they now share one definition rather than two copies that can drift.

Regression: a cleanup failure during disk-credential adoption still resolves
with the committed token, attempts cleanup, performs no network refresh, and
does not mark the account needsReauth. Reverting the first hunk fails it with a
rejected promise.

(cherry picked from commit f45fb1c)
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 31, 2026 19:09
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 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-08-31T19:15:24.847817Z c418819 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.

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

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@coderabbitai

coderabbitai Bot commented Aug 31, 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: 3f1b0220-87ab-4ac0-9a17-c7cf675f3640

📥 Commits

Reviewing files that changed from the base of the PR and between 6123be3 and c418819.

📒 Files selected for processing (3)
  • src/oauth/index.ts
  • src/oauth/store.ts
  • tests/oauth-refresh.test.ts

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


📝 Walkthrough

Walkthrough

OAuth refresh handling now persists validated attempt metadata and cleanup markers under a shared mutation lock. Anthropic refreshes distinguish definitive rejection from uncertain outcomes, use compare-and-swap cleanup, and preserve replay guards when provider state is unknown. Tests cover failure, recovery, and race conditions.

Changes

OAuth refresh durability

Layer / File(s) Summary
Refresh intent contracts and atomic mutations
src/oauth/store.ts
Refresh intents validate attempt and cleanup fields, use the configuration mutation lock, and support snapshot- and attempt-aware cleanup operations.
Provider refresh lifecycle and recovery
src/oauth/index.ts
Anthropic refreshes track dispatch state, persist cleanup-pending markers, retry lock-conflicted writes, and preserve intents for uncertain provider outcomes.
Durability and race-condition coverage
tests/oauth-refresh.test.ts
Tests cover transient failures, cleanup errors, lock contention, persistence failures, malformed intents, stale generations, compare-and-swap races, and disk-credential adoption.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🟡 Moderate · up to c4188

This PR improves recovery from stale Anthropic refresh markers, but it can clear replay protection after a 5xx response even when the provider may already have consumed the token, potentially causing token reuse and reauthentication; cleanup persistence failures can also leave an account blocked. Merge should wait for these recovery and response-classification cases to be addressed or explicitly accepted.

Sequence Diagram(s)

sequenceDiagram
  participant RefreshFlow
  participant IntentStore
  participant AnthropicProvider
  participant CredentialStore
  RefreshFlow->>IntentStore: Write refresh intent with attemptId
  RefreshFlow->>IntentStore: Mark cleanup pending before dispatch
  RefreshFlow->>AnthropicProvider: Dispatch refresh request
  AnthropicProvider-->>RefreshFlow: Return credential or definitive rejection
  RefreshFlow->>CredentialStore: Persist refreshed credential
  RefreshFlow->>IntentStore: Clear intent with compare-and-swap checks
Loading

Suggested reviewers: ingwannu

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.04% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 describes the OAuth refresh-intent change and identifies Anthropic and transient failures. It is related to the primary changes, although it does not mention the important distinction betwee…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title describes the OAuth refresh-intent change and identifies Anthropic and transient failures. It is related to the primary changes, although it does not mention the important distinction between safe cleanup and uncertain post-dispatch outcomes.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/2989-anthropic-refresh-intent

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

리뷰 · 우선순위 70 / 80

설명

이 PR은 기여자 @luvs01#2989(maintainer-sponsored)를 지금 dev HEAD 6123be31f 위에 다시 올린 랜딩입니다. 커밋 여덟 개가 저자 것이고 손대지 않았다고 본문에 적혀 있습니다. Anthropic 새로고침이 디스크에 refresh intent를 쓴 다음, 끝이 아닌 오류를 다시 던지면서 그 표시를 안 지웁니다.

지금 체크아웃의 src/oauth/index.ts 680–702행이 그 경로입니다. writeOAuthRefreshIntent 다음에 def.refresh 를 부르고, catch에서 terminal(error) 가 아니면 표시를 남긴 채 던집니다. terminal() 은 400/401과 grant/revoke 코드 정도만 자격 증명이 죽은 걸로 봅니다. 평범한 503은 끝이 아니라서 표시가 디스크에 남고, 다음 시도가 같은 generation intent를 보고 토큰이 이미 쓰였을 수 있다고 판단해 OAuthLoginRequiredError 로 수동 재로그인을 요구합니다. 한 번의 일시 오류가 재인증이 됩니다.

기존 테스트 Anthropic transient failures do not mark needsReauth 는 첫 throw만 봅니다. 표시가 해를 끼치는 지점은 재진입입니다. 그래서 그 테스트는 고장난 경로에서도 초록입니다. 이 PR은 시도가 공급자에 도달하지 못했거나 분명히 거절당했을 때는 표시를 지우고, 결과가 불확실할 때(타임아웃, 끊긴 연결, 본문 파싱 실패, 로컬 저장 실패)는 남깁니다. 자격 증명이 죽었는지를 묻는 terminal() 과, 이번 시도가 실패로 확정됐는지를 묻는 definitivelyAnswered() 를 갈라 씁니다. 정리는 디스크에 남기고 재개할 수 있어서, unlink가 실패해도 이미 회전된 자격 증명을 가리지 않습니다.

변경량이 src/oauth/index.ts / src/oauth/store.ts / 테스트로 +1040/−32 입니다. SQLITE_BUSY 재시도, cleanup-pending 마커, attemptId 매칭 삭제가 한 묶음입니다. types.ts/config.ts 분할과는 무관합니다. leftover #2989는 이 랜딩이 머지된 뒤에 닫으면 됩니다.

라인 src/oauth/index.ts definitivelyAnswered - AnthropicTokenError 이고 httpStatus 가 있을 때만 참입니다. 본문대로 어댑터가 실패 응답에만 status를 붙이면 503은 지우고 타임아웃은 남깁니다. 다른 공급자 오류 타입이 status를 들고 들어오면 여기선 항상 불확실로 남습니다. 지금은 Anthropic 전용 함수라 맞습니다.
라인 src/oauth/index.ts clearAnthropicRefreshIntentForKnownFailure - 공급자가 이미 답한 뒤에는 호출자 취소를 무시하고 표시를 남기려 합니다. 맞지만, Bun.sleep 재시도 동안 계정 락을 붙잡고 있어서 같은 계정의 다른 새로고침이 기다립니다. 지연 합이 짧아서 보통은 괜찮습니다.
경로 테스트 - 재진입이 깨지는 경로를 기존 테스트가 못 잡았으니, 이 PR 테스트가 두 번째 refreshAnthropicAccountWithLock 호출을 반드시 타야 합니다. 본문 주장을 테스트가 실제로 고정하는지만 보면 됩니다.
경로 leftover #2989 - 랜딩 머지 후 Landed via #3111 로 닫으면 됩니다. 지금은 열어둡니다.

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

  • 503 같은 “거절은 확실, 자격은 안 죽음”을 지우는 정책이 모든 Anthropic 오류 status에 적용돼도 되는지 (재사용 탐지와 충돌하지 않는지)
  • 이 큰 스토어 패치를 round-2 prio70 열차와 병렬로 넣을지
  • leftover #2989를 머지 직후 바로 닫을지

너의 추천
머지 쪽으로 진행하는 것을 추천합니다. 지금 HEAD는 일시 503 한 번에 재로그인을 요구할 수 있고, 기존 테스트는 그걸 못 봅니다. 표시를 지울 조건이 자격 사망과 시도 확정을 나눠서, 불확실한 회전을 Blind replay하지 않습니다. 테스트가 재진입을 고정하는지만 확인하고 Protect dev 리뷰 후 랜딩하면 됩니다.

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

@lidge-jun

Copy link
Copy Markdown
Owner Author

Administrator bypass and security-review record\n\nOwner authorization was explicit in Codex session 01a05a34-1e3a-73f2-8607-15e517cbec11 on 2026-09-01 KST. Exact head: c418819. Current exact-head check rollup: 0 failing, 0 pending. Independent security review PASS: refresh-intent cleanup adds no token logging, replay bypass, unsafe race, or secret exposure; all three changed files were covered. Using the maintain/admin pull_request bypass documented in MAINTAINERS.md because the author cannot approve their own PR. The bypass is recorded; it does not waive CI or security review.

@lidge-jun
lidge-jun merged commit 2869ec0 into dev Sep 1, 2026
27 checks passed
@lidge-jun
lidge-jun deleted the codex/2989-anthropic-refresh-intent branch September 1, 2026 00:10
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