@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -218,6 +218,97 @@ export function saveCodexAccountCredentialIfGeneration(
218
218
  });
219
219
  }
220
220
 
221
+ /**
222
+ * Commit a refreshed credential to its owner AND to any record that is provably an untouched
223
+ * duplicate of the pre-refresh credential (#2892 gap 3).
224
+ *
225
+ * A refresh normally rotates the refresh token, and the owner CAS above changes only the owner's
226
+ * record. A second non-deleted record holding the same grant that is not participating in the
227
+ * flight therefore keeps a refresh token upstream has just rotated away. Its next refresh sends a
228
+ * dead grant, and `invalid_grant` classifies as `revoked` — retiring a healthy account because we
229
+ * rotated its grant and never told it.
230
+ *
231
+ * Eligibility is deliberately narrow, and each condition earns its place:
232
+ *
233
+ * - Same pre-refresh grant fingerprint, access token, AND expiry. Anything else means the alias was
234
+ * updated concurrently, and repairing only its grant while keeping its own access token would
235
+ * advance a generation without advancing the access-token JWT. `plan-from-token` reads a higher
236
+ * generation as proof of a newer JWT (that is how JWT plan claims supersede a WHAM observation),
237
+ * so that combination lets a stale JWT overwrite an authoritative plan. It would also hand a live
238
+ * forced-refresh joiner back its own 401-rejected bearer: flights are keyed by grant and do not
239
+ * record participants, so a scan cannot tell a dormant alias from a joiner, and the recursion's
240
+ * freshness shortcut does not re-compare against the rejected token.
241
+ * - Same `chatgptAccountId` as the owner. A fingerprint is `sha256` of the refresh token and
242
+ * carries no identity claim; no invariant here guarantees one grant cannot span two account ids,
243
+ * so identity is compared rather than assumed.
244
+ *
245
+ * The rotated access token, refresh token, and expiry move together, keeping a generation bump
246
+ * meaning what every fence already assumes. `replacedAt` and the validation metadata survive
247
+ * because the probe-lease settlement check accepts only an intact `G → G+1` lineage.
248
+ *
249
+ * One lock acquisition and one `persist` for the owner and every alias: `persist` writes the whole
250
+ * store, so a second pass would open a window in which some records hold the dead grant.
251
+ */
252
+ export function commitRefreshedCodexCredentialWithAliases(
253
+ id: string,
254
+ generation: number,
255
+ cred: CodexAccountCredentials,
256
+ ): { committed: boolean; propagatedAliases: { id: string; generation: number }[] } {
257
+ return withCredentialMutationLockSync(() => {
258
+ const store = loadCodexAccountRecordStore();
259
+ const current = store[id];
260
+ if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) {
261
+ return { committed: false, propagatedAliases: [] };
262
+ }
263
+ const priorCredential = current.credential;
264
+ const priorFingerprint = recordGrantFingerprint(current);
265
+ const refreshGrantFingerprint = priorCredential.refreshToken === cred.refreshToken
266
+ ? current.refreshGrantFingerprint ?? refreshGrantFingerprintForToken(cred.refreshToken)
267
+ : refreshGrantFingerprintForToken(cred.refreshToken);
268
+ store[id] = {
269
+ credential: cred,
270
+ generation: generation + 1,
271
+ refreshGrantFingerprint,
272
+ replacedAt: current.replacedAt,
273
+ ...preservedValidationMetadata(current),
274
+ };
275
+
276
+ // Each alias carries its OWN committed generation: aliases need not share one, and the plan
277
+ // reconciliation below is generation-fenced, so an id alone would be reconciled at the wrong fence.
278
+ const propagatedAliases: { id: string; generation: number }[] = [];
279
+ // Nothing to propagate when the grant did not actually rotate: the aliases already hold it.
280
+ // An absent owner identity fails closed: two empty strings compare equal but prove nothing about
281
+ // which upstream account either record was meant to use, and a matching bearer snapshot only
282
+ // shows they copied the same token once. Leave those dormant records alone.
283
+ if (
284
+ priorFingerprint !== undefined
285
+ && priorCredential.refreshToken !== cred.refreshToken
286
+ && !!priorCredential.chatgptAccountId
287
+ ) {
288
+ for (const [aliasId, alias] of Object.entries(store)) {
289
+ if (aliasId === id || alias.deletedAt != null || !alias.credential) continue;
290
+ if (recordGrantFingerprint(alias) !== priorFingerprint) continue;
291
+ if (alias.credential.accessToken !== priorCredential.accessToken) continue;
292
+ if (alias.credential.expiresAt !== priorCredential.expiresAt) continue;
293
+ if (!alias.credential.chatgptAccountId) continue;
294
+ if (alias.credential.chatgptAccountId !== priorCredential.chatgptAccountId) continue;
295
+ const aliasGeneration = alias.generation + 1;
296
+ store[aliasId] = {
297
+ // The alias keeps its OWN chatgptAccountId value, which the guard above proved equal.
298
+ credential: { ...cred, chatgptAccountId: alias.credential.chatgptAccountId },
299
+ generation: aliasGeneration,
300
+ refreshGrantFingerprint,
301
+ replacedAt: alias.replacedAt,
302
+ ...preservedValidationMetadata(alias),
303
+ };
304
+ propagatedAliases.push({ id: aliasId, generation: aliasGeneration });
305
+ }
306
+ }
307
+ persist(store);
308
+ return { committed: true, propagatedAliases };
309
+ });
310
+ }
311
+
221
312
  export function tombstoneCodexAccount(id: string): number {
222
313
  return withCredentialMutationLockSync(() => {
223
314
  const store = loadCodexAccountRecordStore();
@@ -286,7 +377,31 @@ function withCredentialMutationLockSync<T>(fn: () => T): T {
286
377
  }
287
378
 
288
379
  type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number };
289
- type CodexRefreshResult = CodexTokenResult & { credential?: CodexAccountCredentials };
380
+ type CodexRefreshResult = CodexTokenResult & {
381
+ credential?: CodexAccountCredentials;
382
+ /**
383
+ * Records that adopted this refresh's rotated credential through same-grant propagation, each
384
+ * with its own committed generation (#2892 gap 3). Carried on the result so the flight settles
385
+ * every plan in one place rather than the commit doing its own (#2933).
386
+ */
387
+ propagatedAliases?: { id: string; generation: number }[];
388
+ /**
389
+ * Grant the returned credential actually belongs to.
390
+ *
391
+ * Flights are keyed by refresh grant and shared across every account holding that
392
+ * grant, but a flight can resolve to a credential from a DIFFERENT grant: the
393
+ * owner's credential may be externally replaced while it waits for the file lock,
394
+ * and the grant-mismatch branch then hands back that replacement. A joiner that
395
+ * only checks its own current grant would CAS-write another account's access and
396
+ * refresh tokens onto itself. The result therefore carries its own provenance.
397
+ */
398
+ resolvedGrantFingerprint?: string;
399
+ /**
400
+ * True when this call's own CAS write produced `generation` — the credential is a
401
+ * refresh of the one the caller was holding, not somebody else's replacement.
402
+ */
403
+ selfRefreshed?: boolean;
404
+ };
290
405
  const MAX_CODEX_REFRESH_FLIGHTS = 32;
291
406
  const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
292
407
  interface RefreshFlight {
@@ -331,7 +446,7 @@ function isRefreshLockStale(path: string): boolean {
331
446
  }
332
447
  }
333
448
 
334
- async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal, fn: () => Promise<T>): Promise<T> {
449
+ export async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal, fn: () => Promise<T>): Promise<T> {
335
450
  hardenConfigDir();
336
451
  const dir = getConfigDir();
337
452
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -375,12 +490,25 @@ async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal,
375
490
  function findFreshCredentialForGrant(
376
491
  refreshGrantFingerprint: string,
377
492
  excludeId: string,
493
+ rejectedAccessToken?: string,
494
+ expectedChatgptAccountId?: string,
378
495
  ): CodexAccountCredentials | null {
379
496
  const now = Date.now();
380
497
  const records = loadCodexAccountRecordStore();
498
+ // Adoption copies another record's access AND refresh tokens onto the caller, so the two records
499
+ // must be the same upstream identity. A grant fingerprint is `sha256` of the refresh token and
500
+ // carries no identity claim, and nothing here guarantees one grant cannot span two accounts, so
501
+ // require both ids to be present and exactly equal rather than inferring identity from the grant.
502
+ if (!expectedChatgptAccountId) return null;
381
503
  for (const [candidateId, candidate] of Object.entries(records)) {
382
504
  if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential) continue;
383
505
  if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue;
506
+ if (!candidate.credential.chatgptAccountId) continue;
507
+ if (candidate.credential.chatgptAccountId !== expectedChatgptAccountId) continue;
508
+ // A sibling alias can hold a still-unexpired copy of the exact token upstream
509
+ // just rejected. Reusing it would bump the generation and replay the identical
510
+ // bearer — a second 401 dressed up as recovery.
511
+ if (rejectedAccessToken !== undefined && candidate.credential.accessToken === rejectedAccessToken) continue;
384
512
  if (candidate.credential.expiresAt > now + REFRESH_SKEW_MS) return candidate.credential;
385
513
  }
386
514
  return null;
@@ -399,14 +527,106 @@ async function notePlanFromRefreshedAccessToken(
399
527
  }
400
528
  }
401
529
 
530
+ /**
531
+ * A forced refresh raised by a rejected bearer. Carries the generation the 401 was
532
+ * observed under so a credential someone else already replaced is never refreshed
533
+ * again, and the rejected token so a sibling alias holding that same token cannot
534
+ * satisfy the refresh.
535
+ */
536
+ type ForcedRefreshFence = { rejectedGeneration: number; rejectedAccessToken: string };
537
+
538
+ /** True once the stored credential has moved off the generation the 401 belongs to. */
539
+ function forcedFenceSuperseded(recordGeneration: number, forced: ForcedRefreshFence | undefined): boolean {
540
+ return forced !== undefined && recordGeneration !== forced.rejectedGeneration;
541
+ }
542
+
543
+ /**
544
+ * Wait for a SHARED promise while honoring only the calling request's cancellation.
545
+ *
546
+ * The awaited work is not the caller's to cancel — other requests are waiting on the
547
+ * same promise — so an aborted caller stops waiting and the work continues to
548
+ * completion for them (#2892 gap 2). The rejection handler prevents an unhandled
549
+ * rejection from the promise this caller walked away from.
550
+ */
551
+ function awaitOwnCancellation<T>(work: Promise<T>, callerSignal?: AbortSignal): Promise<T> {
552
+ if (!callerSignal) return work;
553
+ if (callerSignal.aborted) {
554
+ work.catch(() => {});
555
+ return Promise.reject(callerSignal.reason);
556
+ }
557
+ return new Promise<T>((resolve, reject) => {
558
+ const onAbort = (): void => {
559
+ work.catch(() => {});
560
+ reject(callerSignal.reason);
561
+ };
562
+ callerSignal.addEventListener("abort", onAbort, { once: true });
563
+ work.then(
564
+ value => { callerSignal.removeEventListener("abort", onAbort); resolve(value); },
565
+ err => { callerSignal.removeEventListener("abort", onAbort); reject(err); },
566
+ );
567
+ });
568
+ }
569
+
570
+ /**
571
+ * Refresh a stored pool credential that upstream rejected with a 401, even though its
572
+ * `expiresAt` still looks valid. Ordinary callers must keep using
573
+ * {@link getValidCodexToken}: only a proven rejection justifies spending a refresh.
574
+ *
575
+ * `rotated` is false when the resolved token is byte-identical to the rejected one,
576
+ * which means replaying would earn the same 401 and the caller must not try. That can
577
+ * happen even on a SUCCESSFUL token response: upstream may rotate the refresh grant
578
+ * while returning the same access token. The generation has moved by then, so
579
+ * `generation` reports where the credential actually is — a caller that quarantines
580
+ * on `rotated === false` must fence on the returned value, not on the one it rejected.
581
+ */
582
+ export async function forceRefreshCodexPoolToken(
583
+ id: string,
584
+ options: { rejectedGeneration: number; rejectedAccessToken: string; signal?: AbortSignal },
585
+ ): Promise<CodexTokenResult & { rotated: boolean; selfRefreshed: boolean }> {
586
+ const result = await resolveCodexToken(
587
+ id,
588
+ { rejectedGeneration: options.rejectedGeneration, rejectedAccessToken: options.rejectedAccessToken },
589
+ options.signal,
590
+ );
591
+ return {
592
+ accessToken: result.accessToken,
593
+ chatgptAccountId: result.chatgptAccountId,
594
+ generation: result.generation,
595
+ rotated: result.accessToken !== options.rejectedAccessToken,
596
+ // Only a CAS this call performed itself proves the new credential descends from the
597
+ // rejected one; anything else is somebody else's replacement and must not be treated
598
+ // as this request's own lineage.
599
+ selfRefreshed: result.selfRefreshed === true,
600
+ };
601
+ }
602
+
402
603
  export async function getValidCodexToken(id: string): Promise<CodexTokenResult> {
604
+ const result = await resolveCodexToken(id);
605
+ return {
606
+ accessToken: result.accessToken,
607
+ chatgptAccountId: result.chatgptAccountId,
608
+ generation: result.generation,
609
+ };
610
+ }
611
+
612
+ async function resolveCodexToken(
613
+ id: string,
614
+ forced?: ForcedRefreshFence,
615
+ callerSignal?: AbortSignal,
616
+ ): Promise<CodexRefreshResult> {
617
+ if (callerSignal?.aborted) throw callerSignal.reason;
403
618
  const record = readCodexAccountRecord(id);
404
619
  const cred = record?.deletedAt == null ? record?.credential : undefined;
405
620
  if (!record || !cred) throw new Error("Codex account credential is unavailable; reauthenticate the account.");
406
621
  const refreshGrantFingerprint = recordGrantFingerprint(record);
407
622
  if (!refreshGrantFingerprint) throw new Error("Codex account credential is unavailable; reauthenticate the account.");
408
623
 
409
- if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS) {
624
+ // The freshness shortcut is exactly what makes a 401 on a time-valid token
625
+ // unrecoverable, so a forced caller skips it — but only while the stored credential
626
+ // is still the one that was rejected. Once it has been replaced, the shortcut is
627
+ // correct again and refreshing would burn a rotation for nothing.
628
+ const forcedTargetsStoredCredential = forced !== undefined && !forcedFenceSuperseded(record.generation, forced);
629
+ if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedTargetsStoredCredential) {
410
630
  return { accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId, generation: record.generation };
411
631
  }
412
632
 
@@ -416,13 +636,70 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
416
636
  existing.abort.abort(new CodexCredentialRefreshStaleError());
417
637
  if (refreshLocks.get(refreshGrantFingerprint) === existing) refreshLocks.delete(refreshGrantFingerprint);
418
638
  } else {
419
- const refreshed = await existing.promise;
639
+ const refreshed = await awaitOwnCancellation(existing.promise, callerSignal);
420
640
  const current = readCodexAccountRecord(id);
421
641
  const currentCred = current?.deletedAt == null ? current?.credential : undefined;
642
+ // The flight owner already committed this credential, and it is the one stored
643
+ // for this account: adopt the stored state instead of CAS-writing the identical
644
+ // bytes, which would bump the generation a second time and invalidate the
645
+ // affinity handoff the owner performed against generation+1.
646
+ if (current && currentCred && refreshed.credential
647
+ && currentCred.accessToken === refreshed.credential.accessToken
648
+ && currentCred.refreshToken === refreshed.credential.refreshToken) {
649
+ // A forced caller must still not accept the bearer upstream rejected.
650
+ if (!(forced !== undefined && currentCred.accessToken === forced.rejectedAccessToken)) {
651
+ return {
652
+ accessToken: currentCred.accessToken,
653
+ chatgptAccountId: currentCred.chatgptAccountId,
654
+ generation: current.generation,
655
+ };
656
+ }
657
+ }
658
+ // Flights are keyed by refresh grant, not by account or generation, so this
659
+ // credential may belong to a flight started for a different generation of the
660
+ // same grant. Writing it onto a replacement would undo that replacement.
661
+ //
662
+ // The rejected-token test comes FIRST: a joined flight that resolved back to the
663
+ // bearer upstream rejected proves nothing, and reporting the replacement as
664
+ // "superseded" would hand the caller a token it must not replay.
665
+ //
666
+ // Freshness is tested here too. Supersession says only that SOMEONE replaced the
667
+ // credential — not that what they wrote is usable. An expired G+1 satisfies the
668
+ // generation test and the rejected-bearer test while being certain to earn
669
+ // another 401, and because the caller treats this return as a successful
670
+ // recovery it spends its one replay on it (#2892 gap 1). A stale winner must
671
+ // fall through to a real refresh instead.
672
+ //
673
+ // Stated honestly: this guard is NOT covered by a red-proven test. Reaching this
674
+ // branch needs a live flight that RESOLVES, a stored credential differing from
675
+ // what the flight produced, and that stored credential expired — three attempted
676
+ // interleavings each landed elsewhere (own flight, first adopt-stored branch, or
677
+ // a CAS conflict that rejects for both callers). The guard is one comparison on a
678
+ // path that otherwise returns a known-dead token, and its only effect is to
679
+ // divert to the refresh the caller would have needed anyway.
680
+ if (
681
+ current && currentCred
682
+ && forcedFenceSuperseded(current.generation, forced)
683
+ && currentCred.expiresAt > Date.now() + REFRESH_SKEW_MS
684
+ && !(forced !== undefined && currentCred.accessToken === forced.rejectedAccessToken)
685
+ ) {
686
+ return {
687
+ accessToken: currentCred.accessToken,
688
+ chatgptAccountId: currentCred.chatgptAccountId,
689
+ generation: current.generation,
690
+ };
691
+ }
422
692
  if (
423
693
  current &&
424
694
  currentCred &&
425
695
  refreshed.credential &&
696
+ // Provenance: a flight can resolve to a credential from a DIFFERENT grant when
697
+ // the owner's own credential was replaced while it waited for the lock. Adopting
698
+ // that would copy another account's access and refresh tokens onto this one.
699
+ refreshed.resolvedGrantFingerprint === refreshGrantFingerprint &&
700
+ // A joined flight that resolved to the rejected token proves nothing; fall
701
+ // through and open a real refresh instead of bumping the generation.
702
+ !(forced !== undefined && refreshed.credential.accessToken === forced.rejectedAccessToken) &&
426
703
  recordGrantFingerprint(current) === refreshGrantFingerprint
427
704
  ) {
428
705
  if (!saveCodexAccountCredentialIfGeneration(id, current.generation, refreshed.credential)) {
@@ -434,18 +711,39 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
434
711
  accessToken: refreshed.credential.accessToken,
435
712
  chatgptAccountId: refreshed.credential.chatgptAccountId,
436
713
  generation,
714
+ // This joiner performed its own CAS onto its own record, so the resulting
715
+ // generation is its own lineage even though another caller drove the fetch.
716
+ selfRefreshed: true,
717
+ resolvedGrantFingerprint: refreshGrantFingerprint,
437
718
  };
438
719
  }
439
- return getValidCodexToken(id);
720
+ return resolveCodexToken(id, forced, callerSignal);
440
721
  }
441
722
  }
442
723
 
443
724
  if (refreshLocks.size >= MAX_CODEX_REFRESH_FLIGHTS) throw new CodexCredentialRefreshBusyError();
444
725
 
726
+ /*
727
+ * The flight's lifetime belongs to the FLIGHT, not to whichever caller happened to
728
+ * open it (#2892 gap 2).
729
+ *
730
+ * Flights are shared: later callers on the same grant join `existing.promise` rather
731
+ * than starting their own. Folding `callerSignal` into the flight's signal therefore
732
+ * gave one arbitrary waiter the power to abort the token request out from under every
733
+ * other waiter — and the joiners have no way to distinguish that from a genuine
734
+ * upstream failure, so a cancelled Codex tab could retire a healthy account for a
735
+ * request that was still running.
736
+ *
737
+ * The initiating caller still gets cancellation: it is waiting on its own await, and
738
+ * `awaitOwnCancellation` below races its wait against its own signal. What it no
739
+ * longer gets is the ability to cancel work other callers depend on: the flight keeps
740
+ * running for the joiners, and its result is still committed. `abort` (stale-flight
741
+ * eviction) and the 30s ceiling remain, because those bound the flight itself.
742
+ */
445
743
  const abort = new AbortController();
446
744
  const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]);
447
745
  let flight!: RefreshFlight;
448
- const refreshPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise<CodexRefreshResult> => {
746
+ const fetchPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise<CodexRefreshResult> => {
449
747
  const current = readCodexAccountRecord(id);
450
748
  const lockedRecord = readCodexAccountRecord(id);
451
749
  const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined;
@@ -459,19 +757,35 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
459
757
  chatgptAccountId: lockedCred.chatgptAccountId,
460
758
  generation: startGeneration,
461
759
  credential: lockedCred,
760
+ // This credential belongs to a DIFFERENT grant than the flight was opened
761
+ // for. Tagging it keeps a joiner from adopting it as its own.
762
+ ...(lockedRefreshGrantFingerprint !== undefined
763
+ ? { resolvedGrantFingerprint: lockedRefreshGrantFingerprint }
764
+ : {}),
462
765
  };
463
766
  }
464
767
  throw new CodexCredentialGenerationConflictError();
465
768
  }
466
- if (lockedCred.expiresAt > Date.now() + REFRESH_SKEW_MS) {
769
+ // Third fence point: waiting for the lock can take long enough for another
770
+ // writer to replace the credential. Under the lock the stored generation is
771
+ // authoritative, so a superseded forced refresh stops here rather than
772
+ // spending a rotation on a credential nobody rejected.
773
+ const forcedStillTargetsStored = forced !== undefined && !forcedFenceSuperseded(startGeneration, forced);
774
+ if (lockedCred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedStillTargetsStored) {
467
775
  return {
468
776
  accessToken: lockedCred.accessToken,
469
777
  chatgptAccountId: lockedCred.chatgptAccountId,
470
778
  generation: startGeneration,
471
779
  credential: lockedCred,
780
+ resolvedGrantFingerprint: refreshGrantFingerprint,
472
781
  };
473
782
  }
474
- const sameGrantFreshCredential = findFreshCredentialForGrant(refreshGrantFingerprint, id);
783
+ const sameGrantFreshCredential = findFreshCredentialForGrant(
784
+ refreshGrantFingerprint,
785
+ id,
786
+ forced?.rejectedAccessToken,
787
+ lockedCred.chatgptAccountId,
788
+ );
475
789
  if (sameGrantFreshCredential) {
476
790
  if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, sameGrantFreshCredential)) {
477
791
  throw new CodexCredentialGenerationConflictError();
@@ -481,6 +795,8 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
481
795
  chatgptAccountId: sameGrantFreshCredential.chatgptAccountId,
482
796
  generation: startGeneration + 1,
483
797
  credential: sameGrantFreshCredential,
798
+ resolvedGrantFingerprint: refreshGrantFingerprint,
799
+ selfRefreshed: true,
484
800
  };
485
801
  }
486
802
  const res = await fetch(CHATGPT_TOKEN_URL, {
@@ -496,11 +812,22 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
496
812
  if (!res.ok) {
497
813
  const errText = await res.text().catch(() => "");
498
814
  let errDesc: string;
815
+ let errCodeExact: string | undefined;
499
816
  try {
500
817
  const parsed = JSON.parse(errText) as { error?: string; error_description?: string };
818
+ errCodeExact = typeof parsed.error === "string" ? parsed.error.trim() : undefined;
501
819
  errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${res.status}`;
502
820
  } catch { errDesc = `HTTP ${res.status}`; }
503
- const reason = errDesc.includes("invalidated") || errDesc.includes("revoked") ? "revoked" as const
821
+ // `invalid_grant` is the standard OAuth code for a refresh token that is no longer
822
+ // usable, and upstream sends it bare with no description. Without it here the dead
823
+ // grant is classified "unknown", which callers treat as transient — so the account
824
+ // is never retired and every request repeats the same doomed refresh (#2887).
825
+ //
826
+ // Matched on the exact `error` CODE, not anywhere in the combined text: a transient
827
+ // `server_error` whose description happens to mention invalid_grant would otherwise
828
+ // retire a healthy account, which is the failure this whole change exists to remove.
829
+ const reason = errCodeExact === "invalid_grant"
830
+ || errDesc.includes("invalidated") || errDesc.includes("revoked") ? "revoked" as const
504
831
  : errDesc.includes("expired") ? "expired" as const
505
832
  : "unknown" as const;
506
833
  throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
@@ -524,21 +851,74 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
524
851
  expiresAt: safeExpiresAt,
525
852
  chatgptAccountId: lockedCred.chatgptAccountId,
526
853
  };
527
- if (!saveCodexAccountCredentialIfGeneration(id, startGeneration, updated)) {
854
+ // Commit to the owner and, in the same write, to any record that is still an untouched
855
+ // duplicate of the credential this flight started from (#2892 gap 3). Without this the rotated
856
+ // grant reaches only the owner and live joiners, and a dormant same-grant record is left
857
+ // holding a refresh token upstream has invalidated.
858
+ const commit = commitRefreshedCodexCredentialWithAliases(id, startGeneration, updated);
859
+ if (!commit.committed) {
528
860
  throw new CodexCredentialGenerationConflictError();
529
861
  }
530
- return { accessToken: updated.accessToken, chatgptAccountId: updated.chatgptAccountId, generation: startGeneration + 1, credential: updated };
862
+ if (commit.propagatedAliases.length > 0) {
863
+ console.warn(`[codex-auth] rotated refresh grant propagated to ${commit.propagatedAliases.length} dormant same-grant account record(s)`);
864
+ }
865
+ return {
866
+ accessToken: updated.accessToken,
867
+ chatgptAccountId: updated.chatgptAccountId,
868
+ generation: startGeneration + 1,
869
+ credential: updated,
870
+ // Aliases that adopted this rotated credential travel on the result so the FLIGHT settles
871
+ // their plans in the same single place as the owner's (#2933). Each carries its own committed
872
+ // generation because the plan note is generation-fenced.
873
+ ...(commit.propagatedAliases.length > 0 ? { propagatedAliases: commit.propagatedAliases } : {}),
874
+ // The grant this flight was OPENED for, not the rotated one it produced. Joiners
875
+ // are waiting on that key, and a successful refresh normally rotates the refresh
876
+ // token — tagging the new grant would make every legitimate joiner look foreign.
877
+ resolvedGrantFingerprint: refreshGrantFingerprint,
878
+ selfRefreshed: true,
879
+ };
880
+ });
881
+ /*
882
+ * Plan reconciliation belongs to the FLIGHT, not to whichever caller opened it.
883
+ *
884
+ * The flight outlives its initiating caller by design (gap 2): an aborted owner stops
885
+ * waiting while the shared work still runs and still commits the rotated credential.
886
+ * Reconciling the plan only after the owner's caller-scoped wait therefore dropped it
887
+ * whenever that owner walked away, and a same-account joiner returning through the
888
+ * adopt-stored branch does not reconcile either — so a changed `chatgpt_plan_type`
889
+ * stayed invisible in `codexAccounts[].plan` for the life of the process and skewed
890
+ * plan-selected quota projection. Attaching it to the flight runs it exactly once per
891
+ * committed result, for every waiter, including none.
892
+ */
893
+ const refreshPromise = fetchPromise.then(async (result): Promise<CodexRefreshResult> => {
894
+ await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
895
+ // One settlement path for the whole flight: the refreshing account, then any dormant alias that
896
+ // adopted the same rotated JWT. An alias holds the identical access token, so a changed
897
+ // `chatgpt_plan_type` applies to it too, and its cached-token fast path would never reconcile it.
898
+ for (const alias of result.propagatedAliases ?? []) {
899
+ await notePlanFromRefreshedAccessToken(alias.id, result.accessToken, alias.generation);
900
+ }
901
+ return result;
531
902
  }).finally(() => {
532
903
  if (refreshLocks.get(refreshGrantFingerprint) === flight) refreshLocks.delete(refreshGrantFingerprint);
533
904
  });
534
905
 
535
906
  flight = { promise: refreshPromise, startedAt: Date.now(), abort };
536
907
  refreshLocks.set(refreshGrantFingerprint, flight);
537
- const result = await refreshPromise;
538
- await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
908
+ // The owner waits under its own cancellation too: the flight it opened is already
909
+ // registered, so a joiner that arrives after this caller walks away still receives
910
+ // the committed result.
911
+ const result = await awaitOwnCancellation(refreshPromise, callerSignal);
539
912
  return {
540
913
  accessToken: result.accessToken,
541
914
  chatgptAccountId: result.chatgptAccountId,
542
915
  generation: result.generation,
916
+ // Carry the flight's provenance out to the caller: the owner is the one whose CAS
917
+ // produced this generation, and a forced caller needs that to know whether the new
918
+ // credential descends from the one it was holding.
919
+ ...(result.selfRefreshed !== undefined ? { selfRefreshed: result.selfRefreshed } : {}),
920
+ ...(result.resolvedGrantFingerprint !== undefined
921
+ ? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
922
+ : {}),
543
923
  };
544
924
  }
@@ -1,6 +1,11 @@
1
1
  import { getCodexAccountCredential } from "./account-store";
2
2
  import { isAccountNeedsReauth } from "./account-runtime-state";
3
- import { MAIN_CODEX_ACCOUNT_ID, isMainAccountTokenLive } from "./main-account";
3
+ import {
4
+ MAIN_CODEX_ACCOUNT_ID,
5
+ hasMainAccountRefreshGrant,
6
+ isMainAccountCredentialUsable,
7
+ isMainAccountTokenLive,
8
+ } from "./main-account";
4
9
  import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id";
5
10
  import type { OcxConfig } from "../types";
6
11
  import { isNativeMainTrafficBlocked } from "./native-profile-startup";
@@ -27,13 +32,15 @@ export function isCodexAccountUsable(
27
32
  // A legacy pool row with the sentinel makes an active `__main__` ambiguous.
28
33
  // Fail closed until the authenticated compatibility-delete path removes it.
29
34
  if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false;
30
- if (isAccountNeedsReauth(accountId)) return false;
35
+ if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return false;
31
36
  // A selection-only caller owns the recovery/drain fence and will reject main
32
37
  // before reservation or token materialization. Treat cached main as a routing
33
38
  // candidate without touching the credential file so affinity is not rebound.
34
39
  if (options.nativeMainSelectionOnly) return true;
35
- // Main account: credential is the read-only ~/.codex/auth.json token (Option A).
36
- return (options.isMainAccountTokenLive ?? isMainAccountTokenLive)();
40
+ // Main account: a refresh grant is enough to route; materialization refreshes before I/O.
41
+ return options.isMainAccountTokenLive
42
+ ? options.isMainAccountTokenLive()
43
+ : isMainAccountCredentialUsable();
37
44
  }
38
45
  const exists = (config.codexAccounts ?? [])
39
46
  .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
@@ -44,9 +44,14 @@ const CODEX_TARGET_TRIPLE_BODY = "[a-z0-9_]+-[a-z0-9_]+-[a-z0-9_]+(?:-[a-z0-9_]+
44
44
  * `"C:\Program Files\...\codex.exe" app-server` still reach GetOwner.
45
45
  * Also admits official target-triple basenames such as
46
46
  * `codex-x86_64-pc-windows-msvc.exe`.
47
+ *
48
+ * The optional `.opencodex-real` sits where `backupPathFor` actually puts it — after
49
+ * the stem and BEFORE the extension — and deliberately not before the triple. Written
50
+ * the other way it admits `codex.opencodex-real-x86_64-pc-windows-msvc.exe`, a name
51
+ * nothing produces, and pays GetOwner for it.
47
52
  */
48
53
  export const WINDOWS_CODEX_BASENAME_CANDIDATE_RE = new RegExp(
49
- `(^|[/\\\\\\s'"=])codex(-${CODEX_TARGET_TRIPLE_BODY})?([.]exe|[.]cmd)?['"]?(\\s|$)`,
54
+ `(^|[/\\\\\\s'"=])codex(-${CODEX_TARGET_TRIPLE_BODY})?([.]opencodex-real)?([.]exe|[.]cmd|[.]ps1)?['"]?(\\s|$)`,
50
55
  "i",
51
56
  );
52
57
 
@@ -57,6 +62,38 @@ const CODEX_TARGET_TRIPLE_BASENAME_RE = new RegExp(
57
62
  `^codex-${CODEX_TARGET_TRIPLE_BODY}(?:\\.exe|\\.cmd)?$`,
58
63
  );
59
64
 
65
+ /**
66
+ * Launcher basenames a Codex app-server can be started through, including the
67
+ * `.opencodex-real` backups the autostart shim creates.
68
+ *
69
+ * When the shim installs, `backupPathFor` (`src/codex/shim.ts`) renames the original
70
+ * launcher by inserting `.opencodex-real` before its extension, so a shimmed host runs
71
+ * `~/.local/bin/codex.opencodex-real app-server`. Reported by a contributor (#2884) with
72
+ * `ps` output from an affected host: `--restart-codex` matched nothing and left
73
+ * app-servers alive holding stale in-memory catalogs.
74
+ *
75
+ * An EXACT set, kept separate from the target-triple pattern above rather than folded
76
+ * into it by stripping the suffix first. That shortcut is unsafe: normalising
77
+ * `codex-report-generator-worker.opencodex-real` yields a syntactically valid triple
78
+ * and would make an unrelated process a kill target. A triple binary cannot be a shim
79
+ * target anyway — Unix discovery accepts only a PATH entry named `codex`, and Windows
80
+ * refuses a real `codex.exe` outright — so the combination is unreachable, not merely
81
+ * unlisted.
82
+ *
83
+ * `.ps1` and `.cmd` are here because `findWindowsCodexTargets` shims both, and the
84
+ * extensionless form because Unix discovery and the Git-Bash launcher use it. There is
85
+ * deliberately no `.opencodex-real.exe`: Windows installation REFUSES to rename a native
86
+ * `codex.exe`, so that backup cannot exist. Matching it looked like free breadth until a
87
+ * review round put it plainly — this set decides what receives SIGTERM, and a name no
88
+ * installation can produce only widens what a coincidence can hit.
89
+ */
90
+ const CODEX_LAUNCHER_BASENAMES = new Set([
91
+ "codex", "codex.exe", "codex.cmd",
92
+ "codex.opencodex-real",
93
+ "codex.opencodex-real.cmd",
94
+ "codex.opencodex-real.ps1",
95
+ ]);
96
+
60
97
  /** True when a Windows CommandLine is worth paying GetOwner for (current-user scoped later). */
61
98
  export function isWindowsCodexCandidateCommandLine(commandLine: string): boolean {
62
99
  return WINDOWS_CODEX_BASENAME_CANDIDATE_RE.test(commandLine)
@@ -158,7 +195,7 @@ function tokenBasename(token: string): string {
158
195
 
159
196
  function isCodexExecutableToken(token: string): boolean {
160
197
  const base = tokenBasename(token);
161
- return base === "codex" || base === "codex.exe" || base === "codex.cmd"
198
+ return CODEX_LAUNCHER_BASENAMES.has(base)
162
199
  || CODEX_TARGET_TRIPLE_BASENAME_RE.test(base);
163
200
  }
164
201
 
@@ -282,6 +319,10 @@ export function isCodexAppServerCommandLine(commandLine: string, executable?: st
282
319
  let i = 1;
283
320
  while (i < tokens.length) {
284
321
  const token = tokens[i]!;
322
+ // `--` ends option parsing, so what follows is a prompt for the interactive TUI, not
323
+ // a subcommand. `codex -- app-server` starts a session whose first prompt word is
324
+ // "app-server"; treating it as a match sends SIGTERM to somebody's live session.
325
+ if (token === "--") return false;
285
326
  if (token.startsWith("-")) {
286
327
  i = advancePastCodexGlobalOption(tokens, i);
287
328
  continue;
@@ -418,9 +459,9 @@ function windowsSnapshotPowerShellCommand(): string {
418
459
  // Newlines keep -Command as a real script (space-joined statements need ';').
419
460
  // Double-quoted format string so `t expands to a real tab.
420
461
  // Codex candidates only: basename token codex / codex.exe / codex.cmd /
421
- // official target-triple binaries (optional closing quote after the
422
- // basename), or code-mode-host — not incidental substrings like a repo
423
- // path with "opencodex".
462
+ // codex.ps1, their .opencodex-real shim backups, official target-triple
463
+ // binaries (optional closing quote after the basename), or code-mode-host —
464
+ // not incidental substrings like a repo path with "opencodex".
424
465
  const basenameMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source);
425
466
  const codeModeMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.source);
426
467
  return [