@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.
Files changed (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -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 { 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";
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
- if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
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) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
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
- clearOAuthRefreshIntent(provider, accountId, generation);
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) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
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
- clearOAuthRefreshIntent(provider, accountId, generation);
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
- clearOAuthRefreshIntent(provider, accountId, generation);
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)) throw 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
- clearOAuthRefreshIntent(provider, accountId, generation);
907
+ if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent);
701
908
  throw new OAuthLoginRequiredError(provider);
702
909
  }
703
910
  } finally {
@@ -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 interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; flightId?: string; staleOwner?: true; uncertain?: true }
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 value = JSON.parse(raw) as Partial<OAuthRefreshIntent>;
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.flightId !== undefined && typeof value.flightId !== "string")
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 { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
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 { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
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 { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
180
+ return uncertainOAuthRefreshIntent(provider, accountId);
111
181
  }
112
182
  }
113
- export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): void {
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 = { version: 1, provider, accountId, generation, createdAt, ...(flightId ? { flightId } : {}) };
118
- atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`);
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
- const current = readOAuthRefreshIntent(provider, accountId);
122
- if (current?.uncertain || current?.generation !== generation || current.flightId !== flightId) return false;
123
- atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify({ ...current, staleOwner: true })}\n`);
124
- return true;
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
- const current = readOAuthRefreshIntent(provider, accountId);
128
- if (!current || current.generation !== generation) return false;
129
- try { unlinkSync(getAuthRefreshIntentPath(provider, accountId)); return true; }
130
- catch (error) { if (errorCode(error) === "ENOENT") return false; throw error; }
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");