@bitkyc08/opencodex 2.89.0 → 2.90.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 (129) hide show
  1. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  2. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
  3. package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
  4. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +2 -2
  7. package/src/adapters/base.ts +4 -0
  8. package/src/adapters/claude-cli/adapter.ts +13 -7
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  10. package/src/adapters/coding-agent/turn.ts +14 -4
  11. package/src/adapters/cursor/protobuf-request.ts +3 -0
  12. package/src/adapters/google-progress-history.ts +19 -1
  13. package/src/adapters/google.ts +28 -9
  14. package/src/adapters/image.ts +5 -3
  15. package/src/adapters/input-media-guard.ts +5 -2
  16. package/src/adapters/kiro-refusal.ts +41 -1
  17. package/src/adapters/ollama-native.ts +1 -0
  18. package/src/adapters/openai-chat/messages.ts +34 -4
  19. package/src/adapters/openai-chat/passthrough.ts +37 -4
  20. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  21. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  22. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  23. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  24. package/src/adapters/openai-chat.ts +7 -6
  25. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  26. package/src/adapters/openai-responses/passthrough.ts +5 -1
  27. package/src/adapters/openai-responses/transport.ts +13 -1
  28. package/src/claude/desktop-3p-library.ts +43 -3
  29. package/src/claude/desktop-3p.ts +20 -10
  30. package/src/claude/desktop-picker-profile.ts +18 -9
  31. package/src/claude/desktop-remote-store-io.ts +2 -2
  32. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  33. package/src/codex/account-lifecycle.ts +19 -6
  34. package/src/codex/account-store.ts +27 -8
  35. package/src/codex/account-usability.ts +4 -1
  36. package/src/codex/auth-api/account-list.ts +10 -5
  37. package/src/codex/auth-api/account-pause-group.ts +2 -22
  38. package/src/codex/auth-api/login-collision.ts +59 -0
  39. package/src/codex/auth-api/login-flow.ts +12 -7
  40. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  41. package/src/codex/auth-collision.ts +31 -4
  42. package/src/codex/auth-context.ts +48 -6
  43. package/src/codex/cli-update-plan.ts +18 -3
  44. package/src/codex/collision-identity.ts +78 -0
  45. package/src/codex/main-account-hard-lock.ts +14 -0
  46. package/src/codex/routing/cooldown-math.ts +5 -0
  47. package/src/codex/routing/failure-window.ts +61 -9
  48. package/src/codex/routing.ts +4 -3
  49. package/src/codex/shim-diagnostics.ts +2 -1
  50. package/src/codex/shim-fingerprint.ts +11 -2
  51. package/src/codex/shim-templates.ts +16 -5
  52. package/src/codex/shim-windows-refresh.ts +224 -0
  53. package/src/codex/shim.ts +45 -21
  54. package/src/combos/failover.ts +8 -1
  55. package/src/config/provider-validation.ts +7 -2
  56. package/src/generated/compatibility-version.json +168 -108
  57. package/src/generated/model-metadata.ts +1 -1
  58. package/src/images/loop.ts +2 -2
  59. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  60. package/src/lib/provider-admission-wait.ts +14 -0
  61. package/src/lib/service-secrets.ts +37 -2
  62. package/src/lib/translator-budget.ts +1 -1
  63. package/src/lib/upstream-retry.ts +15 -0
  64. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  65. package/src/oauth/account-import/parser.ts +15 -2
  66. package/src/oauth/account-import/types.ts +7 -0
  67. package/src/oauth/chatgpt-shared.ts +10 -1
  68. package/src/oauth/dsh.ts +45 -2
  69. package/src/oauth/kiro.ts +44 -1
  70. package/src/oauth/login-flow-state.ts +11 -2
  71. package/src/providers/claude-cli-usage.ts +20 -5
  72. package/src/providers/initial-model-selection.ts +2 -2
  73. package/src/providers/key-failover.ts +10 -2
  74. package/src/providers/key-store.ts +8 -3
  75. package/src/providers/mistral-transport.ts +207 -0
  76. package/src/providers/openai-sidecar.ts +5 -0
  77. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  78. package/src/providers/quota/antigravity.ts +6 -2
  79. package/src/providers/quota/failure-codes.ts +18 -0
  80. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  81. package/src/providers/quota-routing-cache.ts +1 -1
  82. package/src/providers/quota.ts +5 -1
  83. package/src/providers/registry/entries-extended.ts +6 -1
  84. package/src/providers/routed-config.ts +1 -1
  85. package/src/providers/xai-transport.ts +2 -1
  86. package/src/responses/audio.ts +46 -0
  87. package/src/responses/input-media.ts +37 -15
  88. package/src/responses/parser-content.ts +22 -27
  89. package/src/responses/reasoning-retention.ts +218 -11
  90. package/src/responses/schema.ts +3 -4
  91. package/src/responses/web-search-replay.ts +11 -2
  92. package/src/server/chat-native.ts +8 -7
  93. package/src/server/index/serve-options.ts +8 -2
  94. package/src/server/index/websocket-handler.ts +10 -0
  95. package/src/server/lifecycle.ts +17 -1
  96. package/src/server/management/oauth-account-routes.ts +2 -1
  97. package/src/server/messages-native.ts +11 -0
  98. package/src/server/request-log.ts +16 -7
  99. package/src/server/responses/adapter-continuation.ts +2 -2
  100. package/src/server/responses/adapter-dispatch.ts +2 -2
  101. package/src/server/responses/codex-ws-exchange.ts +3 -0
  102. package/src/server/responses/codex-ws-metadata.ts +44 -0
  103. package/src/server/responses/codex-ws-session.ts +21 -1
  104. package/src/server/responses/combo-stream-preflight.ts +7 -2
  105. package/src/server/responses/compact.ts +2 -0
  106. package/src/server/responses/core-auth.ts +20 -0
  107. package/src/server/responses/core-codex-account.ts +21 -14
  108. package/src/server/responses/core-combo.ts +3 -0
  109. package/src/server/responses/core-opaque-recovery.ts +20 -11
  110. package/src/server/responses/deepseek-image-files.ts +121 -0
  111. package/src/server/responses/empty-completion-guard.ts +8 -0
  112. package/src/server/responses/fetch-helpers.ts +30 -10
  113. package/src/server/responses/input-admission.ts +6 -2
  114. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  115. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  116. package/src/server/responses/passthrough-completion.ts +41 -0
  117. package/src/server/responses/passthrough-delivery.ts +2 -0
  118. package/src/server/responses/passthrough-dispatch.ts +13 -13
  119. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  120. package/src/server/responses/reasoning-archive-error.ts +21 -0
  121. package/src/server/responses/request-prepare.ts +11 -1
  122. package/src/server/responses/request-transport.ts +61 -3
  123. package/src/service/orchestration.ts +17 -0
  124. package/src/types/provider.ts +2 -0
  125. package/src/types/request.ts +16 -1
  126. package/src/types.ts +1 -0
  127. package/src/web-search/loop.ts +2 -2
  128. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  129. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -13,7 +13,7 @@ import { isAccountNeedsReauth } from "../account-runtime-state";
13
13
  import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
14
14
  import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper";
15
15
  import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache";
16
- import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
16
+ import { getMainAccountHardLockStatus, latestMainBlockingResetAt } from "../main-account-hard-lock";
17
17
  import type { OcxConfig } from "../../types";
18
18
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
19
19
  import { providerCodexAccountMode } from "../../providers/registry";
@@ -78,8 +78,55 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
78
78
 
79
79
  const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000;
80
80
 
81
+ /** Traffic headers renew usage, never the independent credit observation (#7377). */
82
+ function poolCreditRenewalDue(config: OcxConfig, accountId: string, now = Date.now()): boolean {
83
+ const account = configuredPoolAccount(config, accountId);
84
+ if (!account || !codexAccountUsesCreditsAfterLimit(config, accountId)
85
+ || isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
86
+ const quota = getAccountQuota(accountId);
87
+ if (codexUsageLimitResetAt(quota, account.plan, now) === undefined) return false;
88
+ const observedAt = quota?.credits?.observedAt;
89
+ if (observedAt !== undefined && Number.isFinite(observedAt)
90
+ && now - observedAt >= 0 && now - observedAt < MAIN_CREDIT_RENEWAL_AGE_MS) return false;
91
+ const attempt = poolQuotaPrimeAttemptedAt.get(accountId);
92
+ return !attempt || attempt.generation !== readCodexAccountRecord(accountId)?.generation
93
+ || now - attempt.at >= MAIN_CREDIT_RENEWAL_AGE_MS;
94
+ }
95
+
81
96
  let mainHardLockRecoveryInFlight: Promise<void> | null = null;
82
- let mainHardLockRecoveryAttempt: { identity: number; credential: number; after: number; delay: number } | undefined;
97
+ /** `floor` and `resetBound` apply only while a predicted reset is (or just was) holding the lock. */
98
+ type MainRecoveryAttempt = { identity: number; credential: number; after: number; delay: number; floor?: number; resetBound?: number };
99
+ let mainHardLockRecoveryAttempt: MainRecoveryAttempt | undefined;
100
+ /** The identity/credential that already waited out its first hold interval. */
101
+ let mainHardLockHoldSeen: { identity: number; credential: number } | undefined;
102
+ const MAIN_HOLD_MIN_INTERVAL_MS = nextQuotaQueryDelay();
103
+
104
+ /** The predicted reset of a blocked main, or undefined when no future reset is known. */
105
+ function predictedMainResetHold(status: ReturnType<typeof getMainAccountHardLockStatus>): number | undefined {
106
+ return status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()
107
+ ? status.resetAt : undefined;
108
+ }
109
+
110
+ /**
111
+ * A hold read is due at its backoff deadline, pulled in to the latest known predicted reset (even
112
+ * one that moved earlier or just passed) but never sooner than one base interval after the last read.
113
+ */
114
+ function mainRecoveryDueAt(attempt: MainRecoveryAttempt, holdResetAt: number | undefined, config: OcxConfig): number {
115
+ // Once a hold elapses the status omits its reset; current evidence may still name a revised one.
116
+ const bound = holdResetAt ?? (attempt.resetBound === undefined ? undefined
117
+ : Math.min(attempt.resetBound, latestMainBlockingResetAt(config) ?? Infinity));
118
+ return bound === undefined ? attempt.after : Math.max(attempt.floor ?? 0, Math.min(attempt.after, bound));
119
+ }
120
+
121
+ /** First sight of a hold for this credential waits one base interval; transient retries do not re-arm it. */
122
+ function armMainResetHold(identity: number, credential: number, holdResetAt: number): void {
123
+ const at = Date.now() + MAIN_HOLD_MIN_INTERVAL_MS;
124
+ mainHardLockHoldSeen = { identity, credential };
125
+ mainHardLockRecoveryAttempt = { identity, credential, delay: MAIN_HOLD_MIN_INTERVAL_MS, after: at, floor: at, resetBound: holdResetAt };
126
+ }
127
+
128
+ const mainHoldSeenFor = (identity: number, credential: number): boolean =>
129
+ mainHardLockHoldSeen?.identity === identity && mainHardLockHoldSeen.credential === credential;
83
130
 
84
131
  /** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
85
132
  export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
@@ -98,10 +145,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
98
145
  || (observedAt !== undefined && now - observedAt >= MAIN_CREDIT_RENEWAL_AGE_MS));
99
146
  };
100
147
  const status = getMainAccountHardLockStatus(config);
148
+ if (status.state !== "blocked") mainHardLockHoldSeen = undefined;
101
149
  if (status.state !== "blocked" && !creditRecoveryNeeded()) { mainHardLockRecoveryAttempt = undefined; return; }
102
150
  if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
103
- // A predicted reset is a scheduling hint, never proof that the hard lock can be lifted.
104
- if (status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()) return;
151
+ // A predicted reset is a scheduling hint, never proof that the hard lock can be lifted. The
152
+ // hold is rechecked on the capped WHAM schedule so an early reset redeemed elsewhere is
153
+ // learned from metadata (#7312); a paused main receives no hold rechecks.
154
+ const holdResetAt = predictedMainResetHold(status);
155
+ if (holdResetAt !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)) return;
105
156
  // Reconcile the owned physical credential before skipping work for an older one.
106
157
  reconcileMainCodexAccountRuntimeState();
107
158
  const physical = readCodexTokens();
@@ -109,9 +160,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
109
160
  const identity = captureMainAccountIdentityGeneration();
110
161
  const credential = getMainQuotaCredentialGeneration();
111
162
  const previous = mainHardLockRecoveryAttempt;
163
+ if (holdResetAt !== undefined && !mainHoldSeenFor(identity, credential)) {
164
+ armMainResetHold(identity, credential, holdResetAt);
165
+ return;
166
+ }
167
+ const sameAttempt = previous?.identity === identity && previous.credential === credential;
168
+ if (sameAttempt && holdResetAt !== undefined) previous.resetBound = holdResetAt;
112
169
  const queryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
113
- if ((previous?.identity === identity && previous.credential === credential
114
- && previous.after > Date.now()) || queryAfter > Date.now()) return;
170
+ if ((sameAttempt && mainRecoveryDueAt(previous, holdResetAt, config) > Date.now()) || queryAfter > Date.now()) return;
115
171
  const lease = tryAcquireNativeMainProfileClaim();
116
172
  if (!lease) return;
117
173
  mainHardLockRecoveryInFlight = (async () => {
@@ -130,16 +186,20 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
130
186
  // Native refresh retains its own grant-scoped refusal; global quarantine would outlive it.
131
187
  return;
132
188
  }
133
- const currentStatus = getMainAccountHardLockStatus(config);
189
+ const currentHold = predictedMainResetHold(getMainAccountHardLockStatus(config));
134
190
  if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
135
191
  || !isMainAccountIdentityGenerationLive(identityGeneration)
136
192
  || (passive && !creditRecoveryNeeded())
137
- || (currentStatus.state === "blocked" && currentStatus.resetAt !== undefined
138
- && currentStatus.resetAt > Date.now())) return;
193
+ || (currentHold !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID))) return;
139
194
  const credential = getMainQuotaCredentialGeneration();
195
+ // A hold that appeared during preparation is first seen now and waits its base interval.
196
+ if (currentHold !== undefined && !mainHoldSeenFor(identityGeneration, credential)) {
197
+ armMainResetHold(identityGeneration, credential, currentHold);
198
+ return;
199
+ }
140
200
  // Reconcile the physical bearer before consulting the previous credential's delay.
141
201
  if (previous?.identity === identityGeneration && previous.credential === credential
142
- && previous.after > Date.now()) return;
202
+ && mainRecoveryDueAt(previous, currentHold, config) > Date.now()) return;
143
203
  const currentQueryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
144
204
  if (currentQueryAfter > Date.now()) {
145
205
  mainHardLockRecoveryAttempt = { identity: identityGeneration, credential,
@@ -162,8 +222,10 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
162
222
  if (transientAuth) mainHardLockRecoveryAttempt = undefined;
163
223
  else {
164
224
  const delay = nextQuotaQueryDelay(previousDelay || undefined);
225
+ const hold = predictedMainResetHold(getMainAccountHardLockStatus(config));
165
226
  mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked" || creditRecoveryNeeded()
166
- ? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter) }
227
+ ? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter),
228
+ ...(hold !== undefined ? { floor: Date.now() + MAIN_HOLD_MIN_INTERVAL_MS, resetBound: hold } : {}) }
167
229
  : undefined;
168
230
  }
169
231
  } else if (queryAfter > Date.now()) {
@@ -186,6 +248,7 @@ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): voi
186
248
  afterTick: () => {
187
249
  void runCodexCooldownRecoveryProbes(config);
188
250
  void runMainAccountHardLockRecovery(config);
251
+ void primeCodexPoolQuotas(config, "credit-renewal");
189
252
  },
190
253
  });
191
254
  }
@@ -265,6 +328,7 @@ export async function primeCodexPoolQuotas(
265
328
  primeInFlight = trackBackgroundWork(`codex-pool-quota-prime:${reason}`, (async () => {
266
329
  const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
267
330
  const stale = pool.filter(a => {
331
+ if (reason === "credit-renewal") return poolCreditRenewalDue(runtimeConfig, a.id);
268
332
  const q = getAccountQuota(a.id);
269
333
  const observationStale = reason === "priority-failback" && q
270
334
  && !codexQuotaHasFreshUsage(q, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
@@ -278,7 +342,10 @@ export async function primeCodexPoolQuotas(
278
342
  if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
279
343
  return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
280
344
  });
345
+ if (reason === "credit-renewal" && stale.length === 0) return;
281
346
  const primeMain = async () => {
347
+ // Main renewal owns its physical-identity claims and passive probe separately.
348
+ if (reason === "credit-renewal") return;
282
349
  const mainLease = tryAcquireNativeMainPrimeLease();
283
350
  if (!mainLease) return;
284
351
  try {
@@ -311,11 +378,12 @@ export async function primeCodexPoolQuotas(
311
378
  primeMain(),
312
379
  mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
313
380
  if (!getCodexAccountCredential(a.id)) return;
381
+ if (reason === "credit-renewal" && !poolCreditRenewalDue(getRuntimeConfig(config), a.id)) return;
314
382
  let result: PoolQuotaResult;
315
383
  try {
316
384
  const quota = getAccountQuota(a.id);
317
- const bypassCachedQuota = reason === "priority-failback" && quota !== null
318
- && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
385
+ const bypassCachedQuota = reason === "credit-renewal" || (reason === "priority-failback" && quota !== null
386
+ && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS));
319
387
  result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime);
320
388
  } catch (error) {
321
389
  // Local quota-flight saturation proves no WHAM request existed for this account.
@@ -4,8 +4,9 @@ import { getCodexAccountCredential } from "./account-store";
4
4
  import { loadConfig } from "../config";
5
5
  import { resolveCodexHomeDir } from "./home";
6
6
  import { extractAccountId } from "../oauth/chatgpt";
7
- import { isSelectableCodexPoolAccount } from "./account-id";
7
+ import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
8
8
  import { codexPlanKey } from "./plan";
9
+ import { getObservedMainCodexCollisionIdentity, sameCodexCollisionIdentity, type CodexCollisionIdentity } from "./collision-identity";
9
10
  import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store";
10
11
 
11
12
  export interface CodexTokens {
@@ -94,17 +95,23 @@ function isWorkspacePlan(plan: unknown): boolean {
94
95
  return !!key && /team|business|enterprise|workspace|edu/.test(key);
95
96
  }
96
97
 
97
- // Main login and managed pool accounts are separate duplicate buckets.
98
- // Inside the pool, personal and workspace subscriptions are also separate buckets.
99
- // Within each pool bucket, keep the original ChatGPT account id + email collision guard.
98
+ // Main login and pool accounts share the account id + email collision guard.
99
+ // Personal and workspace subscriptions remain separate buckets.
100
100
  export function checkAccountIdCollision(
101
101
  chatgptAccountId: string,
102
102
  email?: string | null,
103
103
  plan?: unknown,
104
104
  excludeAccountId?: string | null,
105
+ main: CodexCollisionIdentity | null = getObservedMainCodexCollisionIdentity() ?? null,
105
106
  ): { collision: true; reason: string } | { collision: false } {
106
107
  const candidateEmail = normalizedEmail(email);
107
108
  const candidateWorkspace = isWorkspacePlan(plan);
109
+
110
+ if (excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && main
111
+ && sameCodexCollisionIdentity({ accountId: chatgptAccountId, email, plan }, main)) {
112
+ return { collision: true, reason: "Account is already configured as the native Codex main account." };
113
+ }
114
+
108
115
  for (const account of loadConfig().codexAccounts ?? []) {
109
116
  if (excludeAccountId && account.id === excludeAccountId) continue;
110
117
  if (!isSelectableCodexPoolAccount(account)) continue;
@@ -117,3 +124,23 @@ export function checkAccountIdCollision(
117
124
  }
118
125
  return { collision: false };
119
126
  }
127
+
128
+ export type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
129
+
130
+ /** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
131
+ export function readMainAuth(authPath = join(resolveCodexHomeDir(), "auth.json")): MainAuth {
132
+ const main = readCodexTokensResult(authPath, { bounded: true });
133
+ if (main.status !== "invalid") return main;
134
+ try {
135
+ const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
136
+ if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
137
+ const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
138
+ const apiKeyOnly = envelope.tokens == null
139
+ && (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
140
+ && typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
141
+ return apiKeyOnly ? { status: "api-key-only" } : main;
142
+ } catch {
143
+ return main;
144
+ }
145
+ }
146
+
@@ -97,6 +97,7 @@ import type { DataPlaneAdmission } from "../server/auth-cors";
97
97
  import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
98
98
  import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
99
99
  import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
100
+ import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity";
100
101
 
101
102
  /**
102
103
  * A request-owned bearer cannot inspect the physical main credential for its plan, but cached
@@ -324,6 +325,8 @@ export type CodexAuthContext =
324
325
  writerGeneration: number;
325
326
  /** Captured before async credential work; never reconstructed after the upstream response. */
326
327
  mainQuotaWriter?: MainQuotaWriter;
328
+ /** Immutable credential proof captured while materializing this physical attempt's headers. */
329
+ mainQuotaDispatch?: MainQuotaDispatch;
327
330
  reserveAuthorization?: MainReserveAuthorization;
328
331
  accessToken: string;
329
332
  chatgptAccountId: string;
@@ -1080,6 +1083,33 @@ async function awaitMainAccountPolicyBindingSettled(
1080
1083
  if (!settled) throw new CodexMainProfileDrainingError();
1081
1084
  }
1082
1085
 
1086
+ /** Memory-only ownership check shared by Direct admission and pre-auth hard-lock routing. */
1087
+ export async function mainAccountHardLockAppliesToCaller(
1088
+ headers: Headers,
1089
+ policy: CodexAuthPolicyConfig,
1090
+ options: Pick<ResolveCodexAuthContextOptions,
1091
+ "substituteMainCredentialForDirect" | "signal" | "mainAccountPolicyBindingWaitMs"> = {},
1092
+ ): Promise<boolean> {
1093
+ if (options.substituteMainCredentialForDirect === true) return true;
1094
+ if (!hasCallerCodexBearer(headers)) return false;
1095
+ if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
1096
+ return callerMatchesObservedMain(headers);
1097
+ }
1098
+
1099
+ /**
1100
+ * Resolve ownership once, then hand back a synchronous proof. Main identity or its observed bearer
1101
+ * can change across the await; the hard-lock reroute rechecks the proof with no await between it
1102
+ * and the body rewrite, so a replacement main's lock never reroutes the original caller.
1103
+ */
1104
+ export async function mainAccountHardLockCallerProof(
1105
+ headers: Headers,
1106
+ policy: CodexAuthPolicyConfig,
1107
+ options: Parameters<typeof mainAccountHardLockAppliesToCaller>[2] = {},
1108
+ ): Promise<(() => boolean) | null> {
1109
+ if (!await mainAccountHardLockAppliesToCaller(headers, policy, options)) return null;
1110
+ return options.substituteMainCredentialForDirect === true ? () => true : () => callerMatchesObservedMain(headers);
1111
+ }
1112
+
1083
1113
  /**
1084
1114
  * Resolve the credential for one Codex turn. A main hard-lock reached through unpinned pool
1085
1115
  * selection is annotated with the earliest known pool recovery, so the refusal can name a retry
@@ -1172,8 +1202,7 @@ async function resolveCodexAuthContextUnannotated(
1172
1202
  // Trusted substitution still has to claim and validate stored main below.
1173
1203
  if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
1174
1204
  if (!substituteStoredMain) {
1175
- if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
1176
- if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
1205
+ if (await mainAccountHardLockAppliesToCaller(headers, policy, options)) assertMainAccountPolicy(policy);
1177
1206
  assertCallerOwnedMainPoolNotCooled();
1178
1207
  if (reserve) {
1179
1208
  const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
@@ -1422,7 +1451,9 @@ async function resolveCodexAuthContextUnannotated(
1422
1451
  // cool down. Hand back any trial first -- no Pool account is being sent to.
1423
1452
  if (selected === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential) {
1424
1453
  releaseTransientProbeGrant();
1425
- return await resolveCallerOwnedMainContext();
1454
+ const context = await resolveCallerOwnedMainContext();
1455
+ console.info("[codex] Pool selected caller-owned main credential.");
1456
+ return context;
1426
1457
  }
1427
1458
  if (!selected) {
1428
1459
  // A retry that excluded a failed Pool account may still use the validated caller-owned
@@ -1432,9 +1463,12 @@ async function resolveCodexAuthContextUnannotated(
1432
1463
  if (
1433
1464
  requestScopedMainCredential
1434
1465
  && fixedAccountId === undefined
1466
+ && !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID))
1435
1467
  && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
1436
1468
  ) {
1437
- return await resolveCallerOwnedMainContext();
1469
+ const context = await resolveCallerOwnedMainContext();
1470
+ console.info("[codex] Pool used caller-owned main credential: no selectable stored account.");
1471
+ return context;
1438
1472
  }
1439
1473
  if (fixedAccountId !== undefined) {
1440
1474
  if (modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)) {
@@ -1533,6 +1567,7 @@ async function resolveCodexAuthContextUnannotated(
1533
1567
  // deferred credential must never become request auth through that fallback.
1534
1568
  try {
1535
1569
  assertCodexAccountValidationReady(accountId);
1570
+ if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION);
1536
1571
  assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
1537
1572
  } catch (cause) {
1538
1573
  // Nothing will reach upstream, so give the trial back instead of leaving the held account
@@ -1590,9 +1625,12 @@ async function resolveCodexAuthContextUnannotated(
1590
1625
  // alternate is eligible. A validated caller may still serve this request,
1591
1626
  // just as it can after an upstream rejection, without changing Pool state.
1592
1627
  if (requestScopedMainCredential && fixedAccountId === undefined
1628
+ && !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID))
1593
1629
  && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
1594
1630
  && !callerIsCooledPoolAccount(headers, config, accountId)) {
1595
- return await resolveCallerOwnedMainContext();
1631
+ const context = await resolveCallerOwnedMainContext();
1632
+ console.info("[codex] Pool used caller-owned main credential: stored account cooldown.");
1633
+ return context;
1596
1634
  }
1597
1635
  throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
1598
1636
  }
@@ -1660,6 +1698,7 @@ async function resolveCodexAuthContextUnannotated(
1660
1698
  try {
1661
1699
  const token = await getValidCodexToken(accountId, { signal: options.signal });
1662
1700
  assertCodexAccountValidationReady(accountId);
1701
+ if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION);
1663
1702
  assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
1664
1703
  return bindPoolCreditPolicy({
1665
1704
  kind: "pool",
@@ -1681,7 +1720,8 @@ async function resolveCodexAuthContextUnannotated(
1681
1720
  releaseTransientProbeGrant();
1682
1721
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1683
1722
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1684
- if (cause instanceof CodexPoolAccountCreditsOffError) throw cause;
1723
+ // Post-refresh eligibility refusals do not invalidate a healthy credential.
1724
+ if (cause instanceof CodexPoolAccountCreditsOffError || cause instanceof CodexPoolAuthenticationError) throw cause;
1685
1725
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
1686
1726
  markAccountNeedsReauth(accountId, writerGeneration);
1687
1727
  }
@@ -1766,6 +1806,8 @@ export function materializeCodexUpstreamAuth(
1766
1806
  if (ctx.kind === "main-pool") {
1767
1807
  ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter);
1768
1808
  assertMainAccountPolicy(options.config);
1809
+ ctx.mainQuotaDispatch = ctx.mainQuotaWriter && isMainQuotaWriterLive(ctx.mainQuotaWriter)
1810
+ ? selectedMainQuotaDispatch(selected) : undefined;
1769
1811
  }
1770
1812
  assertMaterializedReserve(selected, ctx, options);
1771
1813
  return selected;
@@ -213,9 +213,15 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget
213
213
  * reverse: they are output paths, so ambient values would write Node artifacts to
214
214
  * arbitrary external directories on exit. ELF/Darwin loader controls (LD_* and
215
215
  * DYLD_*) are also removed: they can load code or redirect loader output before
216
- * npm runs. This is a denylist for process-startup/output channels, not a claim
217
- * that every ambient variable is allowlisted. Ordinary OS environment, proxy
218
- * settings and the supported NODE_EXTRA_CA_CERTS extension remain available.
216
+ * npm runs. OpenSSL's startup inputs are removed with them: OPENSSL_CONF names
217
+ * a configuration file the crypto library reads at startup, OPENSSL_CONF_INCLUDE
218
+ * resolves relative includes inside that configuration, and OPENSSL_MODULES /
219
+ * OPENSSL_ENGINES name the directories its provider and engine search loads
220
+ * shared modules from. None of these is input the updater child needs, so
221
+ * ambient values stay out of the spawned npm. This is a denylist for
222
+ * process-startup/output channels, not a claim that every ambient variable is
223
+ * allowlisted. Ordinary OS environment, proxy settings and the supported
224
+ * NODE_EXTRA_CA_CERTS extension remain available.
219
225
  */
220
226
  const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
221
227
  "node_options",
@@ -227,6 +233,15 @@ const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
227
233
  // escaping the owned temp root regardless of the isolated --cache.
228
234
  "node_v8_coverage",
229
235
  "node_redirect_warnings",
236
+ // OpenSSL startup inputs: OPENSSL_CONF points the library at an ambient
237
+ // startup configuration file (OPENSSL_CONF_INCLUDE resolves that config's
238
+ // relative includes); OPENSSL_MODULES and OPENSSL_ENGINES point its
239
+ // provider/engine search at ambient directories that supply loadable
240
+ // shared modules.
241
+ "openssl_conf",
242
+ "openssl_conf_include",
243
+ "openssl_modules",
244
+ "openssl_engines",
230
245
  ]);
231
246
 
232
247
  function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
@@ -0,0 +1,78 @@
1
+ import { extractAccountIdClaims, extractEmail } from "../oauth/chatgpt";
2
+ import type { CodexAccount, OcxConfig } from "../types";
3
+ import { getCodexAccountCredential } from "./account-store";
4
+ import type { CodexTokens } from "./auth-collision";
5
+ import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "./main-account-cache";
6
+ import { codexPlanKey, extractChatgptPlanType } from "./plan";
7
+
8
+ export interface CodexCollisionIdentity {
9
+ accountId: string;
10
+ email?: string | null;
11
+ plan?: unknown;
12
+ }
13
+
14
+ function normalizedEmail(email: string | null | undefined): string | undefined {
15
+ return email?.trim().toLowerCase() || undefined;
16
+ }
17
+
18
+ function workspacePlan(plan: unknown): boolean {
19
+ return /team|business|enterprise|workspace|edu/.test(codexPlanKey(plan) ?? "");
20
+ }
21
+
22
+ /** Missing member evidence cannot establish a distinct subscription within one plan bucket. */
23
+ export function sameCodexCollisionIdentity(a: CodexCollisionIdentity, b: CodexCollisionIdentity): boolean {
24
+ if (!a.accountId || a.accountId !== b.accountId || workspacePlan(a.plan) !== workspacePlan(b.plan)) return false;
25
+ const aEmail = normalizedEmail(a.email);
26
+ const bEmail = normalizedEmail(b.email);
27
+ return !aEmail || !bEmail || aEmail === bEmail;
28
+ }
29
+
30
+ // Ambient scope comparison is memory-only, including when the default-home resolver would probe WSL.
31
+ function homeScope(): string {
32
+ return JSON.stringify([process.env.CODEX_HOME, process.env.HOME, process.env.USERPROFILE, process.env.OPENCODEX_HOME]);
33
+ }
34
+
35
+ let observed: { identity: CodexCollisionIdentity; homeScope: string; generation: number } | undefined;
36
+
37
+ /** Only existing owned lifecycle observations may publish; never call with a request bearer. */
38
+ export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void {
39
+ if (!tokens) { observed = undefined; return; }
40
+ if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string"
41
+ || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return;
42
+ const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)];
43
+ if (claims.some(claim => claim.conflict)) return;
44
+ const accountId = tokens.account_id || claims.find(claim => claim.accountId)?.accountId;
45
+ if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) return;
46
+ const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)]
47
+ .flatMap(email => normalizedEmail(email) ? [normalizedEmail(email)!] : []));
48
+ if (emails.size > 1) return;
49
+ observed = { identity: { accountId, email: [...emails][0],
50
+ plan: extractChatgptPlanType(tokens.id_token, tokens.access_token) },
51
+ homeScope: homeScope(), generation: captureMainAccountIdentityGeneration() };
52
+ }
53
+
54
+ /** Compare known identity only. External logins become visible at the next owned observation. */
55
+ export function getObservedMainCodexCollisionIdentity(): Readonly<CodexCollisionIdentity> | undefined {
56
+ return observed?.homeScope === homeScope() && isMainAccountIdentityGenerationLive(observed.generation)
57
+ ? observed.identity : undefined;
58
+ }
59
+
60
+ export function isStoredCodexMainDuplicate(account: CodexAccount): boolean {
61
+ const main = getObservedMainCodexCollisionIdentity();
62
+ if (account.isMain || !main) return false;
63
+ const credential = getCodexAccountCredential(account.id);
64
+ if (!credential?.chatgptAccountId) return false;
65
+ const storedEmail = normalizedEmail(account.email);
66
+ const email = storedEmail && storedEmail !== normalizedEmail(account.id)
67
+ ? storedEmail : extractEmail(undefined, credential.accessToken);
68
+ return sameCodexCollisionIdentity({ accountId: credential.chatgptAccountId, email,
69
+ plan: codexPlanKey(account.plan) ? account.plan : extractChatgptPlanType(undefined, credential.accessToken) }, main);
70
+ }
71
+
72
+ export const CODEX_MAIN_DUPLICATE_ACTION =
73
+ "Use the native Codex main account, or sign native Codex into a different account to use this stored Pool entry.";
74
+
75
+ export function configuredCodexMainDuplicate(config: Pick<OcxConfig, "codexAccounts">, accountId: string): boolean {
76
+ const account = config.codexAccounts?.find(row => row.id === accountId);
77
+ return !!account && isStoredCodexMainDuplicate(account);
78
+ }
@@ -121,3 +121,17 @@ export function getMainAccountHardLockStatus(
121
121
  export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {
122
122
  return getMainAccountHardLockStatus(config, now).state === "blocked";
123
123
  }
124
+
125
+ /**
126
+ * Scheduling evidence only: the latest blocking reset even after it has passed, so recovery can
127
+ * notice a revised reset that expired between sweeps. Never an admission or unlock signal.
128
+ */
129
+ export function latestMainBlockingResetAt(config: PolicyConfig): number | undefined {
130
+ if (!isMainAccountHardLockEnabled(config)) return undefined;
131
+ const quota = getMainPolicyQuota();
132
+ if (!quota) return undefined;
133
+ const thresholds = resolveMainAccountHardLockThresholds(config);
134
+ const resets = governingWindows(quota)
135
+ .filter(w => validPercent(w.percent) && w.percent >= thresholds[w.kind]).map(w => resetTimestamp(w.resetAt));
136
+ return resets.length > 0 && resets.every(r => r !== undefined) ? Math.max(...(resets as number[])) : undefined;
137
+ }
@@ -6,6 +6,7 @@ import { isThirtyDayOnlyCodexPlan } from "../plan";
6
6
  import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types";
7
7
  import type { CodexQuotaScope } from "./health-store";
8
8
  import type { TransientProbeGrant } from "./thread-affinity";
9
+ import type { MainQuotaDispatch } from "../main-account-cache";
9
10
 
10
11
  export { TERMINAL_SHORT_WINDOW_FRESHNESS_MS } from "../quota-types";
11
12
 
@@ -99,6 +100,10 @@ export type CodexUpstreamOutcomeMeta = {
99
100
  * means the caller cannot supply lineage and the historical unfenced handling stands.
100
101
  */
101
102
  credentialGeneration?: number;
103
+ /** Main credential proof captured before dispatch; null means no owned proof was available. */
104
+ mainFailureWindowDispatch?: MainQuotaDispatch | null;
105
+ /** Pool sample provenance retained by a terminal callback even if its context is reused. */
106
+ failureWindowCredentialGeneration?: number;
102
107
  };
103
108
 
104
109
  export function computeCodexUsageScore(quota: {
@@ -1,5 +1,9 @@
1
1
  import { pinnedCodexAccountId } from "../account-priority";
2
2
  import type { OcxConfig } from "../../types";
3
+ import { beginCodexAccountGenerationLiveCheck, isCodexAccountGenerationLive } from "../account-store";
4
+ import { isMainQuotaDispatchLive } from "../main-account-cache";
5
+ import type { CodexAuthContext } from "../auth-context";
6
+ import type { CodexUpstreamOutcomeMeta } from "./cooldown-math";
3
7
  import {
4
8
  deleteAccountHealth,
5
9
  setAccountHealth,
@@ -14,12 +18,31 @@ export const CODEX_SLIDING_FAILURE_RECOVER_RATIO = 0.1;
14
18
  export const CODEX_SLIDING_FAILURE_RECOVER_SUSTAIN_MS = 30_000;
15
19
  const MAX_SAMPLES = 512;
16
20
 
17
- type Sample = { at: number; failed: boolean };
18
- type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number };
21
+ type Sample = { at: number; failed: boolean; credentialGeneration?: number };
22
+ type WindowState = { samples: Sample[]; degraded: boolean; healthySince?: number; credentialGeneration?: number };
19
23
 
20
24
  const windows = new Map<string, WindowState>();
21
25
  const pinnedDegradeWarned = new Set<string>();
22
26
 
27
+ /** Read only dispatch-time provenance retained by auth materialization, never recapture at arrival. */
28
+ export function codexFailureWindowOutcomeMeta(
29
+ context: CodexAuthContext,
30
+ ): Pick<CodexUpstreamOutcomeMeta, "failureWindowCredentialGeneration" | "mainFailureWindowDispatch"> {
31
+ if (context.kind === "pool") return { failureWindowCredentialGeneration: context.generation };
32
+ return context.kind === "main-pool" ? { mainFailureWindowDispatch: context.mainQuotaDispatch ?? null } : {};
33
+ }
34
+
35
+ /** Fence window samples only; other health and recovery-lease semantics stay with their owners. */
36
+ export function noteCodexFailureWindowOutcome(
37
+ config: OcxConfig, accountId: string, failed: boolean, now: number, meta: CodexUpstreamOutcomeMeta,
38
+ ): void {
39
+ if (meta.mainFailureWindowDispatch !== undefined
40
+ && (!meta.mainFailureWindowDispatch || !isMainQuotaDispatchLive(meta.mainFailureWindowDispatch))) return;
41
+ const generation = meta.failureWindowCredentialGeneration ?? meta.credentialGeneration;
42
+ if (generation !== undefined && !isCodexAccountGenerationLive(accountId, generation)) return;
43
+ appendSample(config, accountId, failed, now, generation);
44
+ }
45
+
23
46
  export function codexFailureWindowEnabled(config: OcxConfig): boolean {
24
47
  return config.codexFailureWindow !== false;
25
48
  }
@@ -33,11 +56,27 @@ function stateFor(accountId: string): WindowState {
33
56
  return state;
34
57
  }
35
58
 
36
- function recompute(state: WindowState, now: number): void {
37
- const cutoff = now - CODEX_SLIDING_FAILURE_WINDOW_MS;
38
- if (state.samples.length > 0 && state.samples[0]!.at <= cutoff) {
39
- state.samples = state.samples.filter(sample => sample.at > cutoff);
59
+ function recompute(
60
+ accountId: string, state: WindowState, now: number,
61
+ generationLive?: ReturnType<typeof beginCodexAccountGenerationLiveCheck>,
62
+ ): void {
63
+ // One store snapshot per insertion/read, never one file load per retained sample. The
64
+ // unlocked insertion precheck can race another process; provenance must survive it.
65
+ const tagged = state.credentialGeneration !== undefined
66
+ || state.samples.some(sample => sample.credentialGeneration !== undefined);
67
+ const live = generationLive ?? (tagged ? beginCodexAccountGenerationLiveCheck() : undefined);
68
+ // Unknown evidence is not a credential removal: preserve samples and all derived state.
69
+ if (live?.snapshotStatus === "failed") return;
70
+ if (state.credentialGeneration !== undefined && !live?.(accountId, state.credentialGeneration)) {
71
+ state.degraded = false;
72
+ state.healthySince = undefined;
73
+ state.credentialGeneration = undefined;
74
+ pinnedDegradeWarned.delete(accountId);
40
75
  }
76
+ const cutoff = now - CODEX_SLIDING_FAILURE_WINDOW_MS;
77
+ state.samples = state.samples.filter(sample => sample.at > cutoff
78
+ && (sample.credentialGeneration === undefined || live?.(accountId, sample.credentialGeneration)));
79
+ state.credentialGeneration ??= state.samples.find(sample => sample.credentialGeneration !== undefined)?.credentialGeneration;
41
80
  const samples = state.samples.length;
42
81
  const failed = state.samples.reduce((count, sample) => count + (sample.failed ? 1 : 0), 0);
43
82
  const degraded = samples >= CODEX_SLIDING_FAILURE_MIN_SAMPLES
@@ -71,19 +110,32 @@ export function noteCodexFailureWindowSample(
71
110
  accountId: string,
72
111
  failed: boolean,
73
112
  now: number,
113
+ ): void {
114
+ appendSample(config, accountId, failed, now);
115
+ }
116
+
117
+ function appendSample(
118
+ config: OcxConfig, accountId: string, failed: boolean, now: number, credentialGeneration?: number,
74
119
  ): void {
75
120
  if (!codexFailureWindowEnabled(config)) return;
121
+ const existing = windows.get(accountId);
122
+ const tagged = credentialGeneration !== undefined || existing?.credentialGeneration !== undefined
123
+ || existing?.samples.some(sample => sample.credentialGeneration !== undefined);
124
+ const live = tagged ? beginCodexAccountGenerationLiveCheck() : undefined;
125
+ if (live?.snapshotStatus === "failed") return;
126
+ // A stale arrival is not an ownership transition and must not delete a live replacement's state.
127
+ if (credentialGeneration !== undefined && !live?.(accountId, credentialGeneration)) return;
76
128
  const state = stateFor(accountId);
77
- state.samples.push({ at: now, failed });
129
+ state.samples.push({ at: now, failed, ...(credentialGeneration === undefined ? {} : { credentialGeneration }) });
78
130
  if (state.samples.length > MAX_SAMPLES) state.samples.splice(0, state.samples.length - MAX_SAMPLES);
79
- recompute(state, now);
131
+ recompute(accountId, state, now, live);
80
132
  if (!state.degraded) pinnedDegradeWarned.delete(accountId);
81
133
  }
82
134
 
83
135
  export function isCodexFailureWindowDegraded(accountId: string, now = Date.now()): boolean {
84
136
  const state = windows.get(accountId);
85
137
  if (!state) return false;
86
- recompute(state, now);
138
+ recompute(accountId, state, now);
87
139
  if (!state.degraded) pinnedDegradeWarned.delete(accountId);
88
140
  return state.degraded;
89
141
  }