@bitkyc08/opencodex 2.38.0 → 2.39.0

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 (81) hide show
  1. package/bin/ocx.mjs +58 -6
  2. package/gui/dist/assets/index-D-lchsPw.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/baseten.svg +13 -0
  6. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  7. package/gui/dist/provider-icons/cerebras.svg +26 -0
  8. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  9. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  10. package/gui/dist/provider-icons/featherless.svg +4 -0
  11. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  12. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  13. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  14. package/gui/dist/provider-icons/kilo.svg +13 -0
  15. package/gui/dist/provider-icons/litellm.svg +1 -0
  16. package/gui/dist/provider-icons/minimax.svg +1 -0
  17. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  18. package/gui/dist/provider-icons/nebius.svg +1 -0
  19. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  20. package/gui/dist/provider-icons/nous.svg +149 -0
  21. package/gui/dist/provider-icons/novita.svg +32 -0
  22. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  23. package/gui/dist/provider-icons/parallel.svg +13 -0
  24. package/gui/dist/provider-icons/sambanova.svg +276 -0
  25. package/gui/dist/provider-icons/scaleway.svg +11 -0
  26. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  27. package/gui/dist/provider-icons/synthetic.svg +12 -0
  28. package/gui/dist/provider-icons/together.svg +18 -0
  29. package/gui/dist/provider-icons/umans.svg +30 -0
  30. package/gui/dist/provider-icons/venice.svg +165 -0
  31. package/gui/dist/provider-icons/vultr.svg +15 -0
  32. package/gui/dist/provider-icons/zai.svg +218 -0
  33. package/gui/dist/provider-icons/zenmux.svg +1 -0
  34. package/package.json +1 -1
  35. package/src/adapters/cursor/live-models.ts +1 -0
  36. package/src/adapters/openai-responses.ts +72 -10
  37. package/src/bridge.ts +15 -15
  38. package/src/cli/dispatch.ts +54 -18
  39. package/src/cli/index.ts +420 -30
  40. package/src/cli/integrations.ts +24 -2
  41. package/src/cli/uninstall-plan.ts +86 -0
  42. package/src/codex/account-store.ts +121 -8
  43. package/src/codex/auth-api.ts +202 -33
  44. package/src/codex/catalog/provider-fetch.ts +76 -9
  45. package/src/codex/history-job.ts +10 -0
  46. package/src/codex/history-manifest.ts +35 -2
  47. package/src/codex/history-provider.ts +196 -19
  48. package/src/codex/history-worker.ts +3 -0
  49. package/src/codex/quota-401-recovery.ts +190 -0
  50. package/src/codex/quota-recovery-timing.ts +28 -0
  51. package/src/codex/quota.ts +6 -0
  52. package/src/codex/routing.ts +68 -16
  53. package/src/codex/subagent-model-fallback.ts +4 -1
  54. package/src/config/pending-teardown-names.d.mts +8 -0
  55. package/src/config/pending-teardown-names.mjs +69 -0
  56. package/src/config/pending-teardown.ts +286 -0
  57. package/src/generated/compatibility-version.json +84 -32
  58. package/src/integrations/journal.ts +12 -1
  59. package/src/integrations/writer.ts +82 -7
  60. package/src/lib/process-control.ts +30 -5
  61. package/src/lib/state-store-registrations.ts +8 -0
  62. package/src/oauth/index.ts +220 -13
  63. package/src/oauth/store.ts +220 -18
  64. package/src/responses/spill-store.ts +20 -0
  65. package/src/responses/state.ts +288 -2
  66. package/src/server/management/integration-routes.ts +36 -5
  67. package/src/server/management-api.ts +66 -12
  68. package/src/server/responses/encrypted-payload.ts +18 -1
  69. package/src/server/stop-teardown.ts +84 -0
  70. package/src/service.ts +155 -20
  71. package/src/update/index.ts +36 -4
  72. package/src/update/proxy-liveness-probe.d.mts +6 -0
  73. package/src/update/proxy-liveness-probe.mjs +84 -0
  74. package/src/update/stop-contract.d.mts +2 -0
  75. package/src/update/stop-contract.mjs +15 -0
  76. package/src/update/stop-decision.d.mts +10 -0
  77. package/src/update/stop-decision.mjs +34 -0
  78. package/src/vision/eligibility.ts +19 -1
  79. package/src/vision/index.ts +4 -18
  80. package/gui/dist/assets/index-C14iCj_Q.js +0 -112
  81. package/gui/dist/assets/index-D7PIz7_g.css +0 -1
@@ -263,7 +263,24 @@ function preflight(input: IntegrationWriteInput) {
263
263
  return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const;
264
264
  }
265
265
 
266
- function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: boolean): WriteOutcome {
266
+ /**
267
+ * How a conflicted document is treated.
268
+ *
269
+ * `refuse` is the default and the only behavior that existed: a conflict means
270
+ * something we did not write occupies our paths, or our own block was edited,
271
+ * and guessing which one the user meant to keep is how a toggle deletes work.
272
+ *
273
+ * `overwrite` is the explicit escape hatch. It is never reached by a plain
274
+ * apply -- the caller has to ask for it by name -- because the whole value of
275
+ * the refusal is that it cannot be triggered by accident.
276
+ */
277
+ type ConflictPolicy = "refuse" | "overwrite";
278
+
279
+ function applyOrRefreshIntegration(
280
+ input: IntegrationWriteInput,
281
+ allowAbsent: boolean,
282
+ conflictPolicy: ConflictPolicy = "refuse",
283
+ ): WriteOutcome {
267
284
  const pre = preflight(input);
268
285
  if (pre.failed) return pre.failed;
269
286
  const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
@@ -278,10 +295,20 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
278
295
  `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
279
296
  }
280
297
  if (classified.state === "conflict") {
281
- return refuse(clientId, "conflict", "conflict",
282
- classified.reason === "foreign-edit"
283
- ? `${configPath} changed after opencodex wrote it`
284
- : `${configPath} already contains an opencodex block we did not write`);
298
+ if (conflictPolicy === "refuse") {
299
+ return refuse(clientId, "conflict", "conflict",
300
+ classified.reason === "foreign-edit"
301
+ ? `${configPath} changed after opencodex wrote it`
302
+ : `${configPath} already contains an opencodex block we did not write`);
303
+ }
304
+ /*
305
+ * The caller asked for the overwrite explicitly, so the merge below runs
306
+ * against the document as it stands and our block replaces whatever holds
307
+ * our paths. Everything that makes it recoverable is shared with apply --
308
+ * the snapshot, the atomic write, the compare-before-commit recheck and the
309
+ * journal row all come from the same commit() call -- which is why this is a
310
+ * policy flag on one code path rather than a second implementation.
311
+ */
285
312
  }
286
313
  /*
287
314
  * `unsafe` from the classifier means the document is not one we may write
@@ -327,7 +354,22 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
327
354
  */
328
355
  const base = classified.state === "stale" && record
329
356
  ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
330
- : parsed;
357
+ : classified.state === "conflict" && record
358
+ /*
359
+ * A forced overwrite of a `foreign-edit` conflict drops what the previous
360
+ * record owned for the same reason a stale refresh does: the replacement
361
+ * record covers the paths we are about to write, so a path the old record
362
+ * owned and the new one does not would be stranded forever, unremovable by
363
+ * any later disable.
364
+ *
365
+ * With NO record -- an `unowned-key` conflict -- there is nothing to drop and
366
+ * the merge runs against the user's document directly. That is correct:
367
+ * createdContainerPaths then attributes every container they already had to
368
+ * them, so a later disable removes our leaves and leaves their structure
369
+ * standing.
370
+ */
371
+ ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
372
+ : parsed;
331
373
  // Computed against the document as it stands BEFORE the merge: afterwards
332
374
  // every container exists and "did we create this?" is unanswerable.
333
375
  const created = createdContainerPaths(base, contribution);
@@ -375,7 +417,16 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
375
417
  const snapshot = store.captureSnapshot(clientId, opId, before);
376
418
  const at = new Date(io.now()).toISOString();
377
419
  const entry: JournalEntry = {
378
- opId, clientId, kind: classified.state === "stale" ? "refresh" : "apply", at, configPath,
420
+ /*
421
+ * `overwrite` is its own kind rather than reusing `apply`. The rollback list
422
+ * is the one place a user goes after a mistake, and "applied" is a lie about
423
+ * an operation that replaced a block somebody else wrote.
424
+ */
425
+ opId, clientId,
426
+ kind: classified.state === "conflict"
427
+ ? "overwrite"
428
+ : classified.state === "stale" ? "refresh" : "apply",
429
+ at, configPath,
379
430
  snapshot, resultFingerprint: fingerprint(text), resultAbsent: false, priorRecord: record,
380
431
  };
381
432
  const refreshablePaths = refreshablePathsOf(contribution);
@@ -406,6 +457,23 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
406
457
  return applyOrRefreshIntegration(input, true);
407
458
  }
408
459
 
460
+ /**
461
+ * Apply over a conflicted config, replacing whatever holds our paths.
462
+ *
463
+ * Separate from `applyIntegration` and never a flag on it: a caller has to name
464
+ * this function to get the behavior, so no existing call site can acquire it by
465
+ * passing a default through.
466
+ *
467
+ * What it does NOT relax. `unsafe` still refuses -- a blocked container means the
468
+ * merge would replace a value it cannot reason about, and a snapshot is not a
469
+ * licence for that. `not_installed` and `non_loopback` still refuse; neither is a
470
+ * conflict. And a non-conflict state behaves exactly as apply does, so calling
471
+ * this on a clean file is not a way to skip any other check.
472
+ */
473
+ export function overwriteIntegration(input: IntegrationWriteInput): WriteOutcome {
474
+ return applyOrRefreshIntegration(input, true, "overwrite");
475
+ }
476
+
409
477
  /** Refresh an owned stale block, but never create or reconnect an absent one. */
410
478
  export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
411
479
  return applyOrRefreshIntegration(input, false);
@@ -698,6 +766,13 @@ export function refreshIntegrationCoordinated(
698
766
  return coordinatedWrite(input, refreshIntegration, options);
699
767
  }
700
768
 
769
+ export function overwriteIntegrationCoordinated(
770
+ input: IntegrationWriteInput,
771
+ options?: CoordinatedIntegrationOptions,
772
+ ): Promise<WriteOutcome> {
773
+ return coordinatedWrite(input, overwriteIntegration, options);
774
+ }
775
+
701
776
  export function disableIntegrationCoordinated(
702
777
  input: IntegrationWriteInput,
703
778
  options?: CoordinatedIntegrationOptions,
@@ -29,6 +29,24 @@ export interface GracefulStopIo {
29
29
  waitExit?: (pid: number, timeoutMs: number) => boolean;
30
30
  env?: Record<string, string | undefined>;
31
31
  exitTimeoutMs?: number;
32
+ /**
33
+ * Nonce of the pending-teardown receipt this caller claimed.
34
+ *
35
+ * `ocx stop` sets it because it restores shared client config itself, only after
36
+ * proving a stopped Task Scheduler did not respawn the proxy (#3008). The nonce is what
37
+ * makes the deferral an owned obligation rather than a flag anyone can set: the proxy
38
+ * honours it only when it names the receipt actually on disk. Direct callers omit it
39
+ * and keep the self-contained behaviour.
40
+ */
41
+ deferSharedTeardownNonce?: string;
42
+ /**
43
+ * Endpoint the caller already resolved for this pid.
44
+ *
45
+ * `ocx stop` records this same snapshot in its pending-teardown receipt. Re-reading the
46
+ * runtime file here could pick up a different one, which would make the receipt name an
47
+ * endpoint the stop never contacted — and recovery probes exactly that endpoint.
48
+ */
49
+ runtimeEndpoint?: { hostname: string; port: number };
32
50
  }
33
51
 
34
52
  /**
@@ -67,7 +85,7 @@ export class ProxyOwnershipRefusedError extends Error {}
67
85
  */
68
86
  export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
69
87
  const readRuntime = io.readRuntime ?? readRuntimePort;
70
- const runtime = readRuntime(pid);
88
+ const runtime = io.runtimeEndpoint ?? readRuntime(pid);
71
89
  if (!runtime?.port) return false;
72
90
  const env = io.env ?? process.env;
73
91
  const headers: Record<string, string> = {};
@@ -75,7 +93,14 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
75
93
  if (token) headers["x-opencodex-api-key"] = token;
76
94
  const fetchFn = io.fetchFn ?? fetch;
77
95
  try {
78
- const res = await fetchFn(`http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`, {
96
+ // `ocx stop` asks the proxy NOT to restore shared client config: it does that itself,
97
+ // after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting
98
+ // the child do it means a survivor found seconds later has already lost its config.
99
+ const stopUrl = `http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`
100
+ + (io.deferSharedTeardownNonce
101
+ ? `?deferSharedTeardown=1&teardownNonce=${encodeURIComponent(io.deferSharedTeardownNonce)}`
102
+ : "");
103
+ const res = await fetchFn(stopUrl, {
79
104
  method: "POST",
80
105
  headers,
81
106
  // Hung proxies with many CLOSE_WAIT clients can be slow to accept; give them
@@ -107,10 +132,10 @@ function drainDeadlineMs(): number {
107
132
  }
108
133
 
109
134
  /** Graceful-first stop: management-API drain, then the platform kill ladder. */
110
- export async function stopProxy(pid: number): Promise<void> {
135
+ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<void> {
111
136
  if (!isProcessAlive(pid)) return;
112
- const runtime = readRuntimePort(pid);
113
- const graceful = await stopProxyGracefully(pid);
137
+ const runtime = io.runtimeEndpoint ?? readRuntimePort(pid);
138
+ const graceful = await stopProxyGracefully(pid, io);
114
139
  if (graceful === "refused") {
115
140
  // The proxy refused on purpose (foreign service owns it). Forcing would strip shared
116
141
  // config while that service keeps the proxy alive.
@@ -9,6 +9,7 @@ import { reconcileProviderFetchWarnings } from "../codex/catalog/provider-fetch"
9
9
  import { reconcileModelCacheGeneration } from "../codex/model-cache";
10
10
  import { reconcilePoolRotationState } from "../codex/pool-rotation";
11
11
  import { reconcileCodexQuotaAccounts } from "../codex/quota";
12
+ import { reconcileQuotaRecovery, sweepExpiredQuotaRecovery } from "../codex/quota-401-recovery";
12
13
  import {
13
14
  listLiveCodexAccountIds,
14
15
  reconcileCodexRoutingHealth,
@@ -84,6 +85,13 @@ export const STATE_STORE_REGISTRATIONS = [
84
85
  },
85
86
  { name: "anthropic-routing-health", sweepExpired: sweepExpiredAnthropicRoutingHealth },
86
87
  { name: "xai-refresh-verdicts", sweepExpired: sweepExpiredXaiPermanentFailureVerdicts },
88
+ {
89
+ name: "codex-quota-401-recovery",
90
+ // Only backoff windows and abandoned leases expire. A spent fence is durable: expiring
91
+ // it would grant the same credential lineage a second refresh (#3019).
92
+ sweepExpired: sweepExpiredQuotaRecovery,
93
+ reconcileGeneration: context => reconcileQuotaRecovery(context.codexAccountIds),
94
+ },
87
95
  {
88
96
  name: "responses-continuation",
89
97
  sweepExpired: sweepExpiredResponseStates,
@@ -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 {