@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +127 -16
- package/gui/dist/assets/index-6HZDkIpX.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
- package/gui/dist/provider-icons/openclaw.svg +54 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/codex-cli-update.ts +96 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +423 -32
- package/src/cli/integrations.ts +24 -2
- package/src/cli/launcher-context.ts +53 -2
- package/src/cli/opencode.ts +126 -33
- package/src/cli/registry.ts +16 -10
- package/src/cli/system-command.ts +6 -1
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +131 -12
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +96 -10
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/cli-install-provenance.ts +795 -0
- package/src/codex/convergence.ts +4 -3
- package/src/codex/credential-mutation-epoch.ts +11 -0
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/main-account.ts +2 -0
- package/src/codex/model-entitlements.ts +430 -27
- package/src/codex/native-profile-manager.ts +4 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/routing.ts +68 -16
- package/src/codex/shim.ts +204 -18
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +155 -67
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +107 -16
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/lib/strict-semver.ts +47 -0
- package/src/lib/windows-elevation.ts +32 -1
- package/src/lib/windows-secret-acl.ts +47 -25
- package/src/lib/windows-service-mutation-lock.ts +133 -0
- package/src/lib/windows-user-principal.ts +15 -17
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +354 -29
- package/src/responses/state.ts +775 -8
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management/model-rows.ts +11 -2
- package/src/server/management/provider-routes.ts +4 -0
- package/src/server/management/system-restart.ts +5 -5
- package/src/server/management-api.ts +72 -13
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/startup-action-control.ts +3 -2
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +749 -53
- package/src/sidecar/candidates.ts +1 -1
- package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +10 -11
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-CowztZdo.js +0 -112
- package/gui/dist/assets/index-jqE_VOKI.css +0 -1
package/src/oauth/index.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
|
|
2
2
|
import { parseCallbackInput } from "./callback-server";
|
|
3
3
|
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
4
|
-
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
4
|
+
import { ConfigMutationLockError, loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
5
5
|
import { maskEmail } from "../lib/privacy";
|
|
6
6
|
import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
|
|
7
|
-
import {
|
|
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";
|
|
8
32
|
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
|
|
9
33
|
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
10
34
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
@@ -559,9 +583,152 @@ function terminal(error:unknown):boolean{
|
|
|
559
583
|
// Local durable-write/read/cleanup failures are operational, not credential
|
|
560
584
|
// death: the provider credential was never rejected or consumed. Never mark
|
|
561
585
|
// 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;
|
|
563
587
|
return isTerminalRefreshError(error);
|
|
564
588
|
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* True when the token endpoint definitively answered and rejected the request.
|
|
592
|
+
*
|
|
593
|
+
* The Anthropic adapter attaches an HTTP status only to an explicit non-success response,
|
|
594
|
+
* which is the retryable rejection this PR handles. Everything else (timeout, dropped
|
|
595
|
+
* connection, a body that could not be read or parsed, or a local persistence fault) leaves
|
|
596
|
+
* the outcome unknown: the server may already have rotated the token, and a blind replay
|
|
597
|
+
* could trip refresh-token-reuse revocation. Those cases must keep the refresh intent.
|
|
598
|
+
*
|
|
599
|
+
* Deliberately narrower than `terminal()`, which asks whether the CREDENTIAL is dead.
|
|
600
|
+
* This asks the different question of whether the ATTEMPT is known to have failed.
|
|
601
|
+
*/
|
|
602
|
+
function definitivelyAnswered(error: unknown): boolean {
|
|
603
|
+
if (error instanceof AnthropicTokenError) return error.httpStatus !== undefined;
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Intent cleanup is secondary to the refresh outcome it protects.
|
|
609
|
+
*
|
|
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.
|
|
613
|
+
*/
|
|
614
|
+
function clearAnthropicRefreshIntentBestEffort(
|
|
615
|
+
provider: string,
|
|
616
|
+
accountId: string,
|
|
617
|
+
expected: OAuthRefreshIntent,
|
|
618
|
+
): boolean {
|
|
619
|
+
try {
|
|
620
|
+
return expected.attemptId
|
|
621
|
+
? clearOAuthRefreshIntentIfMatch(provider, accountId, expected)
|
|
622
|
+
: clearOAuthRefreshIntent(provider, accountId, expected.generation);
|
|
623
|
+
} catch {
|
|
624
|
+
console.warn(
|
|
625
|
+
"[opencodex] Anthropic refresh intent cleanup failed; preserving the durable replay guard.",
|
|
626
|
+
);
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS = [10, 25, 50] as const;
|
|
632
|
+
|
|
633
|
+
function isConfigMutationLockContention(error: unknown): boolean {
|
|
634
|
+
if (!(error instanceof ConfigMutationLockError)) return false;
|
|
635
|
+
const cause = error.cause;
|
|
636
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
637
|
+
? String((cause as { code?: unknown }).code)
|
|
638
|
+
: "";
|
|
639
|
+
return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED";
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function clearAnthropicRefreshIntentForKnownFailure(
|
|
643
|
+
provider: string,
|
|
644
|
+
accountId: string,
|
|
645
|
+
expected: OAuthRefreshIntent,
|
|
646
|
+
cleanupPending: OAuthRefreshIntentCleanupPending,
|
|
647
|
+
refreshError: unknown,
|
|
648
|
+
): Promise<boolean> {
|
|
649
|
+
let marked: OAuthRefreshIntent | undefined;
|
|
650
|
+
for (let attempt = 0; attempt <= ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
651
|
+
try {
|
|
652
|
+
marked = markOAuthRefreshIntentCleanupPending(
|
|
653
|
+
provider,
|
|
654
|
+
accountId,
|
|
655
|
+
expected,
|
|
656
|
+
cleanupPending,
|
|
657
|
+
);
|
|
658
|
+
break;
|
|
659
|
+
} catch (cause) {
|
|
660
|
+
const retryDelay = ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS[attempt];
|
|
661
|
+
if (!isConfigMutationLockContention(cause) || retryDelay === undefined) {
|
|
662
|
+
throw new OAuthRefreshIntentIOError(
|
|
663
|
+
"mark-cleanup-pending",
|
|
664
|
+
cause,
|
|
665
|
+
refreshError,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
// The provider has definitively answered, so caller cancellation no longer changes the
|
|
669
|
+
// settlement obligation. Yield briefly while retaining the per-account refresh lock, then
|
|
670
|
+
// rerun the existing compare-and-swap marker against current disk state.
|
|
671
|
+
await Bun.sleep(retryDelay);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!marked) {
|
|
675
|
+
throw new OAuthRefreshIntentIOError(
|
|
676
|
+
"mark-cleanup-pending",
|
|
677
|
+
new Error("Anthropic refresh intent changed before safe cleanup"),
|
|
678
|
+
refreshError,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let cleared: boolean;
|
|
683
|
+
try {
|
|
684
|
+
cleared = clearOAuthRefreshIntentIfMatch(provider, accountId, marked);
|
|
685
|
+
} catch {
|
|
686
|
+
console.warn(
|
|
687
|
+
"[opencodex] Anthropic refresh intent cleanup failed; retry-safe cleanup remains pending.",
|
|
688
|
+
);
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
if (!cleared) {
|
|
692
|
+
throw new OAuthRefreshIntentIOError(
|
|
693
|
+
"clear-cleanup-pending",
|
|
694
|
+
new Error("Anthropic refresh intent changed during safe cleanup"),
|
|
695
|
+
refreshError,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function resumeAnthropicRefreshIntentCleanup(
|
|
702
|
+
provider: string,
|
|
703
|
+
accountId: string,
|
|
704
|
+
pendingIntent: OAuthRefreshIntent,
|
|
705
|
+
): void {
|
|
706
|
+
let cleared: boolean;
|
|
707
|
+
try {
|
|
708
|
+
cleared = clearOAuthRefreshIntentIfMatch(provider, accountId, pendingIntent);
|
|
709
|
+
} catch (cause) {
|
|
710
|
+
throw new OAuthRefreshIntentIOError(
|
|
711
|
+
"resume-cleanup",
|
|
712
|
+
cause,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (!cleared) {
|
|
716
|
+
throw new OAuthRefreshIntentIOError(
|
|
717
|
+
"resume-cleanup",
|
|
718
|
+
new Error("Pending Anthropic refresh intent changed before cleanup"),
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function clearObservedAnthropicRefreshIntent(
|
|
724
|
+
provider: string,
|
|
725
|
+
accountId: string,
|
|
726
|
+
pendingIntent: OAuthRefreshIntent,
|
|
727
|
+
): boolean {
|
|
728
|
+
return pendingIntent.attemptId
|
|
729
|
+
? clearOAuthRefreshIntentIfMatch(provider, accountId, pendingIntent)
|
|
730
|
+
: clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
|
|
731
|
+
}
|
|
565
732
|
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;}
|
|
566
733
|
function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
|
|
567
734
|
return {
|
|
@@ -647,13 +814,19 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
647
814
|
afterPrePersistRead: deps.afterPrePersistRead,
|
|
648
815
|
});
|
|
649
816
|
if (outcome.superseded) {
|
|
650
|
-
|
|
817
|
+
// The disk credential is already durable here, so cleanup is secondary: an unlink
|
|
818
|
+
// failure must not mask a committed credential by throwing over the return below.
|
|
819
|
+
if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent);
|
|
651
820
|
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
|
|
652
821
|
throw new OAuthLoginRequiredError(provider);
|
|
653
822
|
}
|
|
654
|
-
if (pendingIntent)
|
|
823
|
+
if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent);
|
|
655
824
|
return disk.access;
|
|
656
825
|
}
|
|
826
|
+
if (pendingIntent?.cleanupPending && pendingIntent.generation === generation) {
|
|
827
|
+
resumeAnthropicRefreshIntentCleanup(provider, accountId, pendingIntent);
|
|
828
|
+
pendingIntent = undefined;
|
|
829
|
+
}
|
|
657
830
|
if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) {
|
|
658
831
|
if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError();
|
|
659
832
|
if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) {
|
|
@@ -661,7 +834,9 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
661
834
|
markOAuthRefreshIntentStaleOwner(provider, accountId, generation, deps.replacedStaleFlight.flightId);
|
|
662
835
|
throw new OAuthTokenRefreshStaleError();
|
|
663
836
|
}
|
|
664
|
-
|
|
837
|
+
if (!clearObservedAnthropicRefreshIntent(provider, accountId, pendingIntent)) {
|
|
838
|
+
throw new OAuthTokenRefreshStaleError();
|
|
839
|
+
}
|
|
665
840
|
pendingIntent = undefined;
|
|
666
841
|
}
|
|
667
842
|
}
|
|
@@ -669,7 +844,9 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
669
844
|
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
|
|
670
845
|
throw new OAuthLoginRequiredError(provider);
|
|
671
846
|
}
|
|
672
|
-
if (pendingIntent
|
|
847
|
+
if (pendingIntent && !clearObservedAnthropicRefreshIntent(provider, accountId, pendingIntent)) {
|
|
848
|
+
throw new OAuthTokenRefreshStaleError();
|
|
849
|
+
}
|
|
673
850
|
if (account?.needsReauth) {
|
|
674
851
|
throw new OAuthLoginRequiredError(provider);
|
|
675
852
|
}
|
|
@@ -677,9 +854,15 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
677
854
|
return stored.access;
|
|
678
855
|
}
|
|
679
856
|
|
|
857
|
+
let refreshMayHaveReachedProvider = false;
|
|
858
|
+
let attemptIntent: OAuthRefreshIntent | undefined;
|
|
680
859
|
try {
|
|
681
|
-
writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId);
|
|
860
|
+
attemptIntent = writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId);
|
|
682
861
|
if (deps.signal?.aborted) throw deps.signal.reason;
|
|
862
|
+
// From this point on, even a synchronous client error is conservatively post-dispatch:
|
|
863
|
+
// the provider may have received and rotated the refresh token before the caller learned
|
|
864
|
+
// the outcome.
|
|
865
|
+
refreshMayHaveReachedProvider = true;
|
|
683
866
|
if (deps.flight) deps.flight.dispatched = true;
|
|
684
867
|
const fresh = merged(await def.refresh(stored.refresh, deps.signal), stored);
|
|
685
868
|
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
|
|
@@ -687,17 +870,41 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
687
870
|
afterPrePersistRead: deps.afterPrePersistRead,
|
|
688
871
|
});
|
|
689
872
|
if (outcome.superseded) {
|
|
690
|
-
|
|
873
|
+
if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent);
|
|
691
874
|
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
|
|
692
875
|
throw new OAuthLoginRequiredError(provider);
|
|
693
876
|
}
|
|
694
|
-
|
|
877
|
+
// The rotated credential is durable now. A cleanup failure must not turn that committed
|
|
878
|
+
// success into a refresh failure; the old-generation intent remains a conservative guard.
|
|
879
|
+
if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent);
|
|
695
880
|
return fresh.access;
|
|
696
881
|
} catch (error) {
|
|
697
|
-
if (error instanceof OAuthMutationBusyError) throw error;
|
|
698
|
-
if (!terminal(error))
|
|
882
|
+
if (error instanceof OAuthMutationBusyError || error instanceof OAuthTokenRefreshStaleError) throw error;
|
|
883
|
+
if (!terminal(error)) {
|
|
884
|
+
// A non-terminal failure tells the caller to retry, but the intent outlived it, so
|
|
885
|
+
// the next attempt hit the pending-intent branch above and raised
|
|
886
|
+
// OAuthLoginRequiredError. One 503 locked the account out of refresh until manual
|
|
887
|
+
// re-auth even after upstream recovered.
|
|
888
|
+
//
|
|
889
|
+
// Only clear the intent when the server DEFINITIVELY answered and rejected the
|
|
890
|
+
// request. The adapter attaches an HTTP status only to that explicit non-success
|
|
891
|
+
// response. A timeout, a dropped connection, or an unreadable/unparseable body
|
|
892
|
+
// carries no status: the server may already have
|
|
893
|
+
// rotated the token, and replaying it could trip refresh-token-reuse revocation.
|
|
894
|
+
// Those outcomes keep the intent so the guard still refuses a blind replay.
|
|
895
|
+
if ((!refreshMayHaveReachedProvider || definitivelyAnswered(error)) && attemptIntent) {
|
|
896
|
+
await clearAnthropicRefreshIntentForKnownFailure(
|
|
897
|
+
provider,
|
|
898
|
+
accountId,
|
|
899
|
+
attemptIntent,
|
|
900
|
+
refreshMayHaveReachedProvider ? "definitive-rejection" : "pre-dispatch",
|
|
901
|
+
error,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
699
906
|
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
|
|
700
|
-
|
|
907
|
+
if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent);
|
|
701
908
|
throw new OAuthLoginRequiredError(provider);
|
|
702
909
|
}
|
|
703
910
|
} finally {
|
package/src/oauth/store.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { createHash, randomUUID } from "node:crypto";
|
|
20
20
|
import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
|
-
import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
22
|
+
import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret, withConfigMutationLockSync } from "../config";
|
|
23
23
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
24
24
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
25
25
|
import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget";
|
|
@@ -67,23 +67,93 @@ export function getAuthRefreshIntentLockPath(provider: string, accountId: string
|
|
|
67
67
|
export function getAuthRefreshIntentPath(provider: string, accountId: string): string {
|
|
68
68
|
return `${getAuthRefreshIntentLockPath(provider, accountId)}.json`;
|
|
69
69
|
}
|
|
70
|
-
export
|
|
70
|
+
export type OAuthRefreshIntentCleanupPending = "pre-dispatch" | "definitive-rejection";
|
|
71
|
+
export interface OAuthRefreshIntent {
|
|
72
|
+
version: 1;
|
|
73
|
+
provider: string;
|
|
74
|
+
accountId: string;
|
|
75
|
+
generation: string;
|
|
76
|
+
createdAt: number;
|
|
77
|
+
attemptId?: string;
|
|
78
|
+
flightId?: string;
|
|
79
|
+
staleOwner?: true;
|
|
80
|
+
uncertain?: true;
|
|
81
|
+
cleanupPending?: OAuthRefreshIntentCleanupPending;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class OAuthRefreshIntentIOError extends Error {
|
|
85
|
+
readonly code = "OAUTH_REFRESH_INTENT_IO";
|
|
86
|
+
constructor(
|
|
87
|
+
readonly operation: "write-intent" | "mark-cleanup-pending" | "clear-cleanup-pending" | "resume-cleanup",
|
|
88
|
+
cause?: unknown,
|
|
89
|
+
readonly refreshError?: unknown,
|
|
90
|
+
) {
|
|
91
|
+
super(`OAuth refresh intent ${operation} failed`, cause ? { cause } : undefined);
|
|
92
|
+
this.name = "OAuthRefreshIntentIOError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Both compare-and-swap sites in this file — the OAuth file lock and the refresh-intent
|
|
97
|
+
// file — must agree on what "the same file" means, so they share one snapshot shape and
|
|
98
|
+
// one identity check (`snapshot`/`sameSnapshot` below) rather than two copies that can drift.
|
|
99
|
+
type OAuthRefreshIntentFileSnapshot = LockSnapshot;
|
|
100
|
+
|
|
101
|
+
export interface OAuthRefreshIntentMutationHooks {
|
|
102
|
+
beforeMarkCommit?: () => void;
|
|
103
|
+
beforeClearRecheck?: () => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
class OAuthRefreshIntentChangedError extends Error {}
|
|
107
|
+
|
|
108
|
+
function uncertainOAuthRefreshIntent(provider: string, accountId: string): OAuthRefreshIntent {
|
|
109
|
+
return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
71
112
|
function parseOAuthRefreshIntent(
|
|
72
113
|
provider: string,
|
|
73
114
|
accountId: string,
|
|
74
115
|
raw: string,
|
|
75
116
|
): OAuthRefreshIntent {
|
|
76
|
-
const
|
|
117
|
+
const parsed: unknown = JSON.parse(raw);
|
|
118
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
119
|
+
return uncertainOAuthRefreshIntent(provider, accountId);
|
|
120
|
+
}
|
|
121
|
+
const value = parsed as Partial<OAuthRefreshIntent>;
|
|
77
122
|
if (
|
|
78
123
|
value.version !== 1
|
|
79
124
|
|| value.provider !== provider
|
|
80
125
|
|| value.accountId !== accountId
|
|
81
126
|
|| typeof value.generation !== "string"
|
|
127
|
+
|| !/^[0-9a-f]{64}$/.test(value.generation)
|
|
82
128
|
|| typeof value.createdAt !== "number"
|
|
83
|
-
|| (value.
|
|
129
|
+
|| !Number.isFinite(value.createdAt)
|
|
130
|
+
|| !Number.isInteger(value.createdAt)
|
|
131
|
+
|| value.createdAt < 0
|
|
132
|
+
|| (
|
|
133
|
+
value.attemptId !== undefined
|
|
134
|
+
&& (typeof value.attemptId !== "string" || value.attemptId.length === 0 || value.attemptId.length > 128)
|
|
135
|
+
)
|
|
136
|
+
|| (
|
|
137
|
+
value.flightId !== undefined
|
|
138
|
+
&& (typeof value.flightId !== "string" || value.flightId.length === 0 || value.flightId.length > 128)
|
|
139
|
+
)
|
|
84
140
|
|| (value.staleOwner !== undefined && value.staleOwner !== true)
|
|
141
|
+
|| (value.uncertain !== undefined && value.uncertain !== true)
|
|
142
|
+
|| (
|
|
143
|
+
value.cleanupPending !== undefined
|
|
144
|
+
&& value.cleanupPending !== "pre-dispatch"
|
|
145
|
+
&& value.cleanupPending !== "definitive-rejection"
|
|
146
|
+
)
|
|
147
|
+
|| (
|
|
148
|
+
value.cleanupPending !== undefined
|
|
149
|
+
&& (
|
|
150
|
+
value.attemptId === undefined
|
|
151
|
+
|| value.uncertain === true
|
|
152
|
+
|| value.staleOwner === true
|
|
153
|
+
)
|
|
154
|
+
)
|
|
85
155
|
) {
|
|
86
|
-
return
|
|
156
|
+
return uncertainOAuthRefreshIntent(provider, accountId);
|
|
87
157
|
}
|
|
88
158
|
return value as OAuthRefreshIntent;
|
|
89
159
|
}
|
|
@@ -96,7 +166,7 @@ export function readOAuthRefreshIntent(provider: string, accountId: string): OAu
|
|
|
96
166
|
return parseOAuthRefreshIntent(provider, accountId, readFileSync(path, "utf8"));
|
|
97
167
|
} catch (error) {
|
|
98
168
|
if (errorCode(error) === "ENOENT") return undefined;
|
|
99
|
-
return
|
|
169
|
+
return uncertainOAuthRefreshIntent(provider, accountId);
|
|
100
170
|
}
|
|
101
171
|
}
|
|
102
172
|
|
|
@@ -107,27 +177,159 @@ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAu
|
|
|
107
177
|
return parseOAuthRefreshIntent(provider, accountId, readFileSync(path, "utf8"));
|
|
108
178
|
} catch (error) {
|
|
109
179
|
if (errorCode(error) === "ENOENT") return undefined;
|
|
110
|
-
return
|
|
180
|
+
return uncertainOAuthRefreshIntent(provider, accountId);
|
|
111
181
|
}
|
|
112
182
|
}
|
|
113
|
-
export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string):
|
|
183
|
+
export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): OAuthRefreshIntent {
|
|
114
184
|
const dir = getConfigDir();
|
|
115
185
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
116
186
|
hardenConfigDir();
|
|
117
|
-
const intent: OAuthRefreshIntent = {
|
|
118
|
-
|
|
187
|
+
const intent: OAuthRefreshIntent = {
|
|
188
|
+
version: 1,
|
|
189
|
+
provider,
|
|
190
|
+
accountId,
|
|
191
|
+
generation,
|
|
192
|
+
createdAt,
|
|
193
|
+
attemptId: randomUUID(),
|
|
194
|
+
...(flightId ? { flightId } : {}),
|
|
195
|
+
};
|
|
196
|
+
return withConfigMutationLockSync(() => {
|
|
197
|
+
if (readOAuthRefreshIntent(provider, accountId) !== undefined) {
|
|
198
|
+
throw new OAuthRefreshIntentIOError(
|
|
199
|
+
"write-intent",
|
|
200
|
+
new Error("An OAuth refresh intent already exists; refusing to overwrite its replay guard"),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`);
|
|
204
|
+
return intent;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sameOAuthRefreshIntentAttempt(current: OAuthRefreshIntent, expected: OAuthRefreshIntent): boolean {
|
|
209
|
+
return current.provider === expected.provider
|
|
210
|
+
&& current.accountId === expected.accountId
|
|
211
|
+
&& current.generation === expected.generation
|
|
212
|
+
&& current.createdAt === expected.createdAt
|
|
213
|
+
&& current.attemptId === expected.attemptId
|
|
214
|
+
&& current.flightId === expected.flightId;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function exactOAuthRefreshIntentFile(
|
|
218
|
+
provider: string,
|
|
219
|
+
accountId: string,
|
|
220
|
+
): { intent: OAuthRefreshIntent; snapshot: OAuthRefreshIntentFileSnapshot } | undefined {
|
|
221
|
+
const path = getAuthRefreshIntentPath(provider, accountId);
|
|
222
|
+
try {
|
|
223
|
+
hardenConfigDir();
|
|
224
|
+
hardenExistingSecret(path);
|
|
225
|
+
const file = snapshot(path);
|
|
226
|
+
let intent: OAuthRefreshIntent;
|
|
227
|
+
try { intent = parseOAuthRefreshIntent(provider, accountId, file.bytes); }
|
|
228
|
+
catch { intent = uncertainOAuthRefreshIntent(provider, accountId); }
|
|
229
|
+
return { intent, snapshot: file };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (errorCode(error) === "ENOENT") return undefined;
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function markOAuthRefreshIntentCleanupPending(
|
|
237
|
+
provider: string,
|
|
238
|
+
accountId: string,
|
|
239
|
+
expected: OAuthRefreshIntent,
|
|
240
|
+
cleanupPending: OAuthRefreshIntentCleanupPending,
|
|
241
|
+
hooks: OAuthRefreshIntentMutationHooks = {},
|
|
242
|
+
): OAuthRefreshIntent | undefined {
|
|
243
|
+
return withConfigMutationLockSync(() => {
|
|
244
|
+
const path = getAuthRefreshIntentPath(provider, accountId);
|
|
245
|
+
const observed = exactOAuthRefreshIntentFile(provider, accountId);
|
|
246
|
+
const current = observed?.intent;
|
|
247
|
+
if (
|
|
248
|
+
!observed
|
|
249
|
+
|| !current
|
|
250
|
+
|| expected.attemptId === undefined
|
|
251
|
+
|| current.uncertain
|
|
252
|
+
|| current.staleOwner
|
|
253
|
+
|| !sameOAuthRefreshIntentAttempt(current, expected)
|
|
254
|
+
|| (current.cleanupPending !== undefined && current.cleanupPending !== cleanupPending)
|
|
255
|
+
) return undefined;
|
|
256
|
+
const marked: OAuthRefreshIntent = { ...current, cleanupPending };
|
|
257
|
+
try {
|
|
258
|
+
atomicWriteFile(path, `${JSON.stringify(marked)}\n`, undefined, {
|
|
259
|
+
beforeRename: hooks.beforeMarkCommit,
|
|
260
|
+
validateBeforeRename: targetPath => {
|
|
261
|
+
let latest: OAuthRefreshIntentFileSnapshot;
|
|
262
|
+
try { latest = snapshot(targetPath); }
|
|
263
|
+
catch (error) {
|
|
264
|
+
throw new OAuthRefreshIntentChangedError("OAuth refresh intent changed before marker commit", { cause: error });
|
|
265
|
+
}
|
|
266
|
+
if (!sameSnapshot(observed.snapshot, latest)) {
|
|
267
|
+
throw new OAuthRefreshIntentChangedError("OAuth refresh intent changed before marker commit");
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error instanceof OAuthRefreshIntentChangedError) return undefined;
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
return marked;
|
|
276
|
+
});
|
|
119
277
|
}
|
|
278
|
+
|
|
279
|
+
export function clearOAuthRefreshIntentIfMatch(
|
|
280
|
+
provider: string,
|
|
281
|
+
accountId: string,
|
|
282
|
+
expected: OAuthRefreshIntent,
|
|
283
|
+
hooks: OAuthRefreshIntentMutationHooks = {},
|
|
284
|
+
): boolean {
|
|
285
|
+
return withConfigMutationLockSync(() => {
|
|
286
|
+
const path = getAuthRefreshIntentPath(provider, accountId);
|
|
287
|
+
const observed = exactOAuthRefreshIntentFile(provider, accountId);
|
|
288
|
+
if (!observed) return true;
|
|
289
|
+
const current = observed.intent;
|
|
290
|
+
if (
|
|
291
|
+
current.uncertain
|
|
292
|
+
|| expected.uncertain
|
|
293
|
+
|| current.attemptId === undefined
|
|
294
|
+
|| expected.attemptId === undefined
|
|
295
|
+
|| current.uncertain !== expected.uncertain
|
|
296
|
+
|| current.staleOwner !== expected.staleOwner
|
|
297
|
+
|| current.cleanupPending !== expected.cleanupPending
|
|
298
|
+
|| !sameOAuthRefreshIntentAttempt(current, expected)
|
|
299
|
+
) return false;
|
|
300
|
+
hooks.beforeClearRecheck?.();
|
|
301
|
+
let latest: OAuthRefreshIntentFileSnapshot;
|
|
302
|
+
try { latest = snapshot(path); }
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (errorCode(error) === "ENOENT") return true;
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
if (!sameSnapshot(observed.snapshot, latest)) return false;
|
|
308
|
+
try { unlinkSync(path); return true; }
|
|
309
|
+
catch (error) { if (errorCode(error) === "ENOENT") return true; throw error; }
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
120
313
|
export function markOAuthRefreshIntentStaleOwner(provider: string, accountId: string, generation: string, flightId: string): boolean {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
314
|
+
return withConfigMutationLockSync(() => {
|
|
315
|
+
const current = readOAuthRefreshIntent(provider, accountId);
|
|
316
|
+
if (
|
|
317
|
+
current?.uncertain
|
|
318
|
+
|| current?.cleanupPending
|
|
319
|
+
|| current?.generation !== generation
|
|
320
|
+
|| current.flightId !== flightId
|
|
321
|
+
) return false;
|
|
322
|
+
atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify({ ...current, staleOwner: true })}\n`);
|
|
323
|
+
return true;
|
|
324
|
+
});
|
|
125
325
|
}
|
|
126
326
|
export function clearOAuthRefreshIntent(provider: string, accountId: string, generation: string): boolean {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
327
|
+
return withConfigMutationLockSync(() => {
|
|
328
|
+
const current = readOAuthRefreshIntent(provider, accountId);
|
|
329
|
+
if (!current || current.generation !== generation) return false;
|
|
330
|
+
try { unlinkSync(getAuthRefreshIntentPath(provider, accountId)); return true; }
|
|
331
|
+
catch (error) { if (errorCode(error) === "ENOENT") return false; throw error; }
|
|
332
|
+
});
|
|
131
333
|
}
|
|
132
334
|
export function credentialGeneration(cred: OAuthCredentials): string {
|
|
133
335
|
return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
|