Skip to content

Commit 0b9fb3c

Browse files
committed
fix(oauth): resume retry-safe Anthropic intent cleanup
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.
1 parent c797df3 commit 0b9fb3c

3 files changed

Lines changed: 718 additions & 56 deletions

File tree

src/oauth/index.ts

Lines changed: 139 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,31 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
44
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
55
import { maskEmail } from "../lib/privacy";
66
import { 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";
832
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
933
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
1034
import { 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
*/
590614
function 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+
}
604711
function 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;}
605712
function 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

Comments
 (0)