@@ -4,7 +4,31 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
44import { loadConfig , resolveEnvValue , saveConfig } from "../config" ;
55import { maskEmail } from "../lib/privacy" ;
66import { KiroTokenRefreshError , environmentKiroRoutingMetadata , loginKiro , refreshKiroToken , settleKiroLoginTransaction } from "./kiro" ;
7- import { getAccountCredential , getAccountCredentialWithStatus , getAccountSet , removeAccount , saveAccountCredential , saveCredential , setActiveAccount , getCredential , credentialGeneration , createOAuthRefreshIntentLock , mergeAccountCredential , markAccountNeedsReauthIfGeneration , readOAuthRefreshIntent , writeOAuthRefreshIntent , markOAuthRefreshIntentStaleOwner , clearOAuthRefreshIntent , normalizeAuthStoreBuffer , OAuthMutationBusyError } from "./store" ;
7+ import {
8+ OAuthMutationBusyError ,
9+ OAuthRefreshIntentIOError ,
10+ clearOAuthRefreshIntent ,
11+ clearOAuthRefreshIntentIfMatch ,
12+ createOAuthRefreshIntentLock ,
13+ credentialGeneration ,
14+ getAccountCredential ,
15+ getAccountCredentialWithStatus ,
16+ getAccountSet ,
17+ getCredential ,
18+ markAccountNeedsReauthIfGeneration ,
19+ markOAuthRefreshIntentCleanupPending ,
20+ markOAuthRefreshIntentStaleOwner ,
21+ mergeAccountCredential ,
22+ normalizeAuthStoreBuffer ,
23+ readOAuthRefreshIntent ,
24+ removeAccount ,
25+ saveAccountCredential ,
26+ saveCredential ,
27+ setActiveAccount ,
28+ writeOAuthRefreshIntent ,
29+ type OAuthRefreshIntent ,
30+ type OAuthRefreshIntentCleanupPending ,
31+ } from "./store" ;
832import { loginXai , refreshXaiToken , XAI_LOCAL_CLI_DETACH_WARNING , XaiTokenRequestError } from "./xai" ;
933import { ANTHROPIC_OAUTH_BETA , AnthropicTokenError , loginAnthropic , refreshAnthropicToken } from "./anthropic" ;
1034import { loginKimi , refreshKimiToken } from "./kimi" ;
@@ -559,7 +583,7 @@ function terminal(error:unknown):boolean{
559583 // Local durable-write/read/cleanup failures are operational, not credential
560584 // death: the provider credential was never rejected or consumed. Never mark
561585 // the account needsReauth for broken local persistence infrastructure.
562- if ( error instanceof RefreshIntentIOError ) return false ;
586+ if ( error instanceof RefreshIntentIOError || error instanceof OAuthRefreshIntentIOError ) return false ;
563587 return isTerminalRefreshError ( error ) ;
564588}
565589
@@ -583,24 +607,107 @@ function definitivelyAnswered(error: unknown): boolean {
583607/**
584608 * Intent cleanup is secondary to the refresh outcome it protects.
585609 *
586- * A filesystem failure here must not replace a provider error, a pre-dispatch abort, or a
587- * successfully persisted credential. Leaving the intent in place is the conservative fallback:
588- * it blocks replay until the local persistence problem is repaired .
610+ * Once a credential is already durable, cleanup remains secondary and best-effort. A known
611+ * failed attempt takes the stricter path below: its retry-safe marker must become durable before
612+ * the original provider error can be returned .
589613 */
590614function clearAnthropicRefreshIntentBestEffort (
591615 provider : string ,
592616 accountId : string ,
593- generation : string ,
617+ expected : OAuthRefreshIntent ,
594618) : boolean {
595619 try {
596- return clearOAuthRefreshIntent ( provider , accountId , generation ) ;
620+ return expected . attemptId
621+ ? clearOAuthRefreshIntentIfMatch ( provider , accountId , expected )
622+ : clearOAuthRefreshIntent ( provider , accountId , expected . generation ) ;
597623 } catch {
598624 console . warn (
599625 "[opencodex] Anthropic refresh intent cleanup failed; preserving the durable replay guard." ,
600626 ) ;
601627 return false ;
602628 }
603629}
630+
631+ function clearAnthropicRefreshIntentForKnownFailure (
632+ provider : string ,
633+ accountId : string ,
634+ expected : OAuthRefreshIntent ,
635+ cleanupPending : OAuthRefreshIntentCleanupPending ,
636+ refreshError : unknown ,
637+ ) : boolean {
638+ let marked : OAuthRefreshIntent | undefined ;
639+ try {
640+ marked = markOAuthRefreshIntentCleanupPending (
641+ provider ,
642+ accountId ,
643+ expected ,
644+ cleanupPending ,
645+ ) ;
646+ } catch ( cause ) {
647+ throw new OAuthRefreshIntentIOError (
648+ "mark-cleanup-pending" ,
649+ cause ,
650+ refreshError ,
651+ ) ;
652+ }
653+ if ( ! marked ) {
654+ throw new OAuthRefreshIntentIOError (
655+ "mark-cleanup-pending" ,
656+ new Error ( "Anthropic refresh intent changed before safe cleanup" ) ,
657+ refreshError ,
658+ ) ;
659+ }
660+
661+ let cleared : boolean ;
662+ try {
663+ cleared = clearOAuthRefreshIntentIfMatch ( provider , accountId , marked ) ;
664+ } catch {
665+ console . warn (
666+ "[opencodex] Anthropic refresh intent cleanup failed; retry-safe cleanup remains pending." ,
667+ ) ;
668+ return false ;
669+ }
670+ if ( ! cleared ) {
671+ throw new OAuthRefreshIntentIOError (
672+ "clear-cleanup-pending" ,
673+ new Error ( "Anthropic refresh intent changed during safe cleanup" ) ,
674+ refreshError ,
675+ ) ;
676+ }
677+ return true ;
678+ }
679+
680+ function resumeAnthropicRefreshIntentCleanup (
681+ provider : string ,
682+ accountId : string ,
683+ pendingIntent : OAuthRefreshIntent ,
684+ ) : void {
685+ let cleared : boolean ;
686+ try {
687+ cleared = clearOAuthRefreshIntentIfMatch ( provider , accountId , pendingIntent ) ;
688+ } catch ( cause ) {
689+ throw new OAuthRefreshIntentIOError (
690+ "resume-cleanup" ,
691+ cause ,
692+ ) ;
693+ }
694+ if ( ! cleared ) {
695+ throw new OAuthRefreshIntentIOError (
696+ "resume-cleanup" ,
697+ new Error ( "Pending Anthropic refresh intent changed before cleanup" ) ,
698+ ) ;
699+ }
700+ }
701+
702+ function clearObservedAnthropicRefreshIntent (
703+ provider : string ,
704+ accountId : string ,
705+ pendingIntent : OAuthRefreshIntent ,
706+ ) : boolean {
707+ return pendingIntent . attemptId
708+ ? clearOAuthRefreshIntentIfMatch ( provider , accountId , pendingIntent )
709+ : clearOAuthRefreshIntent ( provider , accountId , pendingIntent . generation ) ;
710+ }
604711function authoritative ( stored :OAuthCredentials , active :boolean , now :( ) => number ) :OAuthCredentials { if ( stored . source !== "local-cli" ) return stored ; const disk = detectGrokCliToken ( ) ; if ( ! disk ) return stored ; const allowed = isSameGrokIdentity ( stored , disk ) || ( active && ! hasComparableGrokIdentity ( stored , disk ) ) ; return allowed && shouldAdoptGrokGeneration ( stored , disk , now ( ) , REFRESH_SKEW_MS ) ?disk :stored ; }
605712function merged ( fresh : OAuthCredentials , previous : OAuthCredentials ) : OAuthCredentials {
606713 return {
@@ -679,18 +786,22 @@ export async function refreshAnthropicAccountWithLock(
679786 const account = getAccountSet ( provider ) ?. accounts . find ( candidate => candidate . id === accountId ) ;
680787 const generation = credentialGeneration ( stored ) ;
681788 let pendingIntent = readOAuthRefreshIntent ( provider , accountId ) ;
789+ if ( pendingIntent ?. cleanupPending && pendingIntent . generation === generation ) {
790+ resumeAnthropicRefreshIntentCleanup ( provider , accountId , pendingIntent ) ;
791+ pendingIntent = undefined ;
792+ }
682793 const disk = newerClaudeCredential ( stored , now ( ) ) ;
683794 if ( disk ) {
684795 const outcome = await mergeAccountCredential ( provider , accountId , disk , {
685796 expectedGeneration : credentialGeneration ( stored ) ,
686797 afterPrePersistRead : deps . afterPrePersistRead ,
687798 } ) ;
688799 if ( outcome . superseded ) {
689- if ( pendingIntent ) clearOAuthRefreshIntent ( provider , accountId , pendingIntent . generation ) ;
800+ if ( pendingIntent ) clearObservedAnthropicRefreshIntent ( provider , accountId , pendingIntent ) ;
690801 if ( outcome . stored . expires > now ( ) + REFRESH_SKEW_MS ) return outcome . stored . access ;
691802 throw new OAuthLoginRequiredError ( provider ) ;
692803 }
693- if ( pendingIntent ) clearOAuthRefreshIntent ( provider , accountId , pendingIntent . generation ) ;
804+ if ( pendingIntent ) clearObservedAnthropicRefreshIntent ( provider , accountId , pendingIntent ) ;
694805 return disk . access ;
695806 }
696807 if ( ! pendingIntent ?. uncertain && pendingIntent ?. generation === generation ) {
@@ -700,15 +811,19 @@ export async function refreshAnthropicAccountWithLock(
700811 markOAuthRefreshIntentStaleOwner ( provider , accountId , generation , deps . replacedStaleFlight . flightId ) ;
701812 throw new OAuthTokenRefreshStaleError ( ) ;
702813 }
703- clearOAuthRefreshIntent ( provider , accountId , generation ) ;
814+ if ( ! clearObservedAnthropicRefreshIntent ( provider , accountId , pendingIntent ) ) {
815+ throw new OAuthTokenRefreshStaleError ( ) ;
816+ }
704817 pendingIntent = undefined ;
705818 }
706819 }
707820 if ( pendingIntent ?. uncertain || pendingIntent ?. generation === generation ) {
708821 await markAccountNeedsReauthIfGeneration ( provider , accountId , generation , writerGeneration ) ;
709822 throw new OAuthLoginRequiredError ( provider ) ;
710823 }
711- if ( pendingIntent ) clearOAuthRefreshIntent ( provider , accountId , pendingIntent . generation ) ;
824+ if ( pendingIntent && ! clearObservedAnthropicRefreshIntent ( provider , accountId , pendingIntent ) ) {
825+ throw new OAuthTokenRefreshStaleError ( ) ;
826+ }
712827 if ( account ?. needsReauth ) {
713828 throw new OAuthLoginRequiredError ( provider ) ;
714829 }
@@ -717,8 +832,9 @@ export async function refreshAnthropicAccountWithLock(
717832 }
718833
719834 let refreshMayHaveReachedProvider = false ;
835+ let attemptIntent : OAuthRefreshIntent | undefined ;
720836 try {
721- writeOAuthRefreshIntent ( provider , accountId , generation , now ( ) , deps . flight ?. flightId ) ;
837+ attemptIntent = writeOAuthRefreshIntent ( provider , accountId , generation , now ( ) , deps . flight ?. flightId ) ;
722838 if ( deps . signal ?. aborted ) throw deps . signal . reason ;
723839 // From this point on, even a synchronous client error is conservatively post-dispatch:
724840 // the provider may have received and rotated the refresh token before the caller learned
@@ -731,13 +847,13 @@ export async function refreshAnthropicAccountWithLock(
731847 afterPrePersistRead : deps . afterPrePersistRead ,
732848 } ) ;
733849 if ( outcome . superseded ) {
734- clearAnthropicRefreshIntentBestEffort ( provider , accountId , generation ) ;
850+ if ( attemptIntent ) clearAnthropicRefreshIntentBestEffort ( provider , accountId , attemptIntent ) ;
735851 if ( outcome . stored . expires > now ( ) + REFRESH_SKEW_MS ) return outcome . stored . access ;
736852 throw new OAuthLoginRequiredError ( provider ) ;
737853 }
738854 // The rotated credential is durable now. A cleanup failure must not turn that committed
739855 // success into a refresh failure; the old-generation intent remains a conservative guard.
740- clearAnthropicRefreshIntentBestEffort ( provider , accountId , generation ) ;
856+ if ( attemptIntent ) clearAnthropicRefreshIntentBestEffort ( provider , accountId , attemptIntent ) ;
741857 return fresh . access ;
742858 } catch ( error ) {
743859 if ( error instanceof OAuthMutationBusyError || error instanceof OAuthTokenRefreshStaleError ) throw error ;
@@ -753,13 +869,19 @@ export async function refreshAnthropicAccountWithLock(
753869 // carries no status: the server may already have
754870 // rotated the token, and replaying it could trip refresh-token-reuse revocation.
755871 // Those outcomes keep the intent so the guard still refuses a blind replay.
756- if ( ! refreshMayHaveReachedProvider || definitivelyAnswered ( error ) ) {
757- clearAnthropicRefreshIntentBestEffort ( provider , accountId , generation ) ;
872+ if ( ( ! refreshMayHaveReachedProvider || definitivelyAnswered ( error ) ) && attemptIntent ) {
873+ clearAnthropicRefreshIntentForKnownFailure (
874+ provider ,
875+ accountId ,
876+ attemptIntent ,
877+ refreshMayHaveReachedProvider ? "definitive-rejection" : "pre-dispatch" ,
878+ error ,
879+ ) ;
758880 }
759881 throw error ;
760882 }
761883 await markAccountNeedsReauthIfGeneration ( provider , accountId , generation , writerGeneration ) ;
762- clearAnthropicRefreshIntentBestEffort ( provider , accountId , generation ) ;
884+ if ( attemptIntent ) clearAnthropicRefreshIntentBestEffort ( provider , accountId , attemptIntent ) ;
763885 throw new OAuthLoginRequiredError ( provider ) ;
764886 }
765887 } finally {
0 commit comments