@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -97,6 +97,8 @@ 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";
101
+ import { AgentAssertionUnsupportedError, authorizationCredential, hasAgentAssertionAuthorization } from "../lib/authorization-scheme";
100
102
 
101
103
  /**
102
104
  * A request-owned bearer cannot inspect the physical main credential for its plan, but cached
@@ -324,6 +326,8 @@ export type CodexAuthContext =
324
326
  writerGeneration: number;
325
327
  /** Captured before async credential work; never reconstructed after the upstream response. */
326
328
  mainQuotaWriter?: MainQuotaWriter;
329
+ /** Immutable credential proof captured while materializing this physical attempt's headers. */
330
+ mainQuotaDispatch?: MainQuotaDispatch;
327
331
  reserveAuthorization?: MainReserveAuthorization;
328
332
  accessToken: string;
329
333
  chatgptAccountId: string;
@@ -698,7 +702,7 @@ async function authorizeReserveCredential(
698
702
 
699
703
  function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } {
700
704
  return {
701
- accessToken: headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "",
705
+ accessToken: authorizationCredential(headers) ?? "",
702
706
  chatgptAccountId: headers.get("chatgpt-account-id") ?? "",
703
707
  };
704
708
  }
@@ -713,7 +717,7 @@ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAcc
713
717
  export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined {
714
718
  const explicit = headers.get("chatgpt-account-id");
715
719
  if (explicit) return explicit;
716
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
720
+ const bearer = authorizationCredential(headers);
717
721
  return bearer ? extractAccountId(undefined, bearer) : undefined;
718
722
  }
719
723
 
@@ -804,7 +808,7 @@ function assertMainAccountPolicy(
804
808
 
805
809
  /** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */
806
810
  function callerMatchesObservedMain(headers: Headers): boolean {
807
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
811
+ const bearer = authorizationCredential(headers);
808
812
  if (!bearer) return false;
809
813
  const effectiveAccountId = headers.get("chatgpt-account-id")
810
814
  ?? extractAccountId(undefined, bearer);
@@ -828,7 +832,7 @@ function sameCredentialMaterial(a: string, b: string): boolean {
828
832
  * cooldown — the safe direction.
829
833
  */
830
834
  function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
831
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
835
+ const bearer = authorizationCredential(headers);
832
836
  if (!bearer) return true;
833
837
  const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
834
838
  if (callerAccountId === undefined) return true;
@@ -1080,6 +1084,33 @@ async function awaitMainAccountPolicyBindingSettled(
1080
1084
  if (!settled) throw new CodexMainProfileDrainingError();
1081
1085
  }
1082
1086
 
1087
+ /** Memory-only ownership check shared by Direct admission and pre-auth hard-lock routing. */
1088
+ export async function mainAccountHardLockAppliesToCaller(
1089
+ headers: Headers,
1090
+ policy: CodexAuthPolicyConfig,
1091
+ options: Pick<ResolveCodexAuthContextOptions,
1092
+ "substituteMainCredentialForDirect" | "signal" | "mainAccountPolicyBindingWaitMs"> = {},
1093
+ ): Promise<boolean> {
1094
+ if (options.substituteMainCredentialForDirect === true) return true;
1095
+ if (!hasCallerCodexBearer(headers)) return false;
1096
+ if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
1097
+ return callerMatchesObservedMain(headers);
1098
+ }
1099
+
1100
+ /**
1101
+ * Resolve ownership once, then hand back a synchronous proof. Main identity or its observed bearer
1102
+ * can change across the await; the hard-lock reroute rechecks the proof with no await between it
1103
+ * and the body rewrite, so a replacement main's lock never reroutes the original caller.
1104
+ */
1105
+ export async function mainAccountHardLockCallerProof(
1106
+ headers: Headers,
1107
+ policy: CodexAuthPolicyConfig,
1108
+ options: Parameters<typeof mainAccountHardLockAppliesToCaller>[2] = {},
1109
+ ): Promise<(() => boolean) | null> {
1110
+ if (!await mainAccountHardLockAppliesToCaller(headers, policy, options)) return null;
1111
+ return options.substituteMainCredentialForDirect === true ? () => true : () => callerMatchesObservedMain(headers);
1112
+ }
1113
+
1083
1114
  /**
1084
1115
  * Resolve the credential for one Codex turn. A main hard-lock reached through unpinned pool
1085
1116
  * selection is annotated with the earliest known pool recovery, so the refusal can name a retry
@@ -1172,8 +1203,7 @@ async function resolveCodexAuthContextUnannotated(
1172
1203
  // Trusted substitution still has to claim and validate stored main below.
1173
1204
  if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
1174
1205
  if (!substituteStoredMain) {
1175
- if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
1176
- if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
1206
+ if (await mainAccountHardLockAppliesToCaller(headers, policy, options)) assertMainAccountPolicy(policy);
1177
1207
  assertCallerOwnedMainPoolNotCooled();
1178
1208
  if (reserve) {
1179
1209
  const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
@@ -1422,7 +1452,9 @@ async function resolveCodexAuthContextUnannotated(
1422
1452
  // cool down. Hand back any trial first -- no Pool account is being sent to.
1423
1453
  if (selected === MAIN_CODEX_ACCOUNT_ID && requestScopedMainCredential) {
1424
1454
  releaseTransientProbeGrant();
1425
- return await resolveCallerOwnedMainContext();
1455
+ const context = await resolveCallerOwnedMainContext();
1456
+ console.info("[codex] Pool selected caller-owned main credential.");
1457
+ return context;
1426
1458
  }
1427
1459
  if (!selected) {
1428
1460
  // A retry that excluded a failed Pool account may still use the validated caller-owned
@@ -1432,9 +1464,12 @@ async function resolveCodexAuthContextUnannotated(
1432
1464
  if (
1433
1465
  requestScopedMainCredential
1434
1466
  && fixedAccountId === undefined
1467
+ && !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID))
1435
1468
  && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
1436
1469
  ) {
1437
- return await resolveCallerOwnedMainContext();
1470
+ const context = await resolveCallerOwnedMainContext();
1471
+ console.info("[codex] Pool used caller-owned main credential: no selectable stored account.");
1472
+ return context;
1438
1473
  }
1439
1474
  if (fixedAccountId !== undefined) {
1440
1475
  if (modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)) {
@@ -1533,6 +1568,7 @@ async function resolveCodexAuthContextUnannotated(
1533
1568
  // deferred credential must never become request auth through that fallback.
1534
1569
  try {
1535
1570
  assertCodexAccountValidationReady(accountId);
1571
+ if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION);
1536
1572
  assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
1537
1573
  } catch (cause) {
1538
1574
  // Nothing will reach upstream, so give the trial back instead of leaving the held account
@@ -1590,9 +1626,12 @@ async function resolveCodexAuthContextUnannotated(
1590
1626
  // alternate is eligible. A validated caller may still serve this request,
1591
1627
  // just as it can after an upstream rejection, without changing Pool state.
1592
1628
  if (requestScopedMainCredential && fixedAccountId === undefined
1629
+ && !(callerMatchesObservedMain(headers) && policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID))
1593
1630
  && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
1594
1631
  && !callerIsCooledPoolAccount(headers, config, accountId)) {
1595
- return await resolveCallerOwnedMainContext();
1632
+ const context = await resolveCallerOwnedMainContext();
1633
+ console.info("[codex] Pool used caller-owned main credential: stored account cooldown.");
1634
+ return context;
1596
1635
  }
1597
1636
  throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
1598
1637
  }
@@ -1660,6 +1699,7 @@ async function resolveCodexAuthContextUnannotated(
1660
1699
  try {
1661
1700
  const token = await getValidCodexToken(accountId, { signal: options.signal });
1662
1701
  assertCodexAccountValidationReady(accountId);
1702
+ if (configuredCodexMainDuplicate(config, accountId)) throw new CodexPoolAuthenticationError(CODEX_MAIN_DUPLICATE_ACTION);
1663
1703
  assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
1664
1704
  return bindPoolCreditPolicy({
1665
1705
  kind: "pool",
@@ -1681,7 +1721,8 @@ async function resolveCodexAuthContextUnannotated(
1681
1721
  releaseTransientProbeGrant();
1682
1722
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1683
1723
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1684
- if (cause instanceof CodexPoolAccountCreditsOffError) throw cause;
1724
+ // Post-refresh eligibility refusals do not invalidate a healthy credential.
1725
+ if (cause instanceof CodexPoolAccountCreditsOffError || cause instanceof CodexPoolAuthenticationError) throw cause;
1685
1726
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
1686
1727
  markAccountNeedsReauth(accountId, writerGeneration);
1687
1728
  }
@@ -1727,7 +1768,7 @@ export class CodexMainSubstitutionUnavailableError extends Error {
1727
1768
 
1728
1769
  /** Dispatch identity comes only from an owned observation of the selected credential. */
1729
1770
  function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined {
1730
- const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
1771
+ const bearer = authorizationCredential(selected);
1731
1772
  if (!bearer) return undefined;
1732
1773
  const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
1733
1774
  return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration());
@@ -1752,6 +1793,8 @@ export function materializeCodexUpstreamAuth(
1752
1793
  ctx: CodexAuthContext,
1753
1794
  options: CodexAuthMaterializationOptions = {},
1754
1795
  ): Headers {
1796
+ // assertion_only must never be answered with a pool, stored-main, or other substituted Bearer.
1797
+ if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
1755
1798
  if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
1756
1799
  const selected = new Headers();
1757
1800
  for (const name of FORWARD_HEADERS) {
@@ -1766,13 +1809,15 @@ export function materializeCodexUpstreamAuth(
1766
1809
  if (ctx.kind === "main-pool") {
1767
1810
  ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter);
1768
1811
  assertMainAccountPolicy(options.config);
1812
+ ctx.mainQuotaDispatch = ctx.mainQuotaWriter && isMainQuotaWriterLive(ctx.mainQuotaWriter)
1813
+ ? selectedMainQuotaDispatch(selected) : undefined;
1769
1814
  }
1770
1815
  assertMaterializedReserve(selected, ctx, options);
1771
1816
  return selected;
1772
1817
  }
1773
1818
  if (ctx.kind === "main" && options.substituteMainCredential !== true
1774
1819
  && !selected.has("chatgpt-account-id")) {
1775
- const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
1820
+ const bearer = authorizationCredential(selected);
1776
1821
  const accountId = bearer ? extractAccountId(undefined, bearer) : undefined;
1777
1822
  if (accountId) selected.set("chatgpt-account-id", accountId);
1778
1823
  }
@@ -1850,6 +1895,7 @@ export async function materializeCodexUpstreamAuthAsync(
1850
1895
  ctx: CodexAuthContext,
1851
1896
  options: CodexAuthMaterializationOptions = {},
1852
1897
  ): Promise<Headers> {
1898
+ if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
1853
1899
  if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
1854
1900
  if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) {
1855
1901
  return materializeReserveUpstreamAuth(headers, ctx, options);
@@ -809,6 +809,10 @@ export function normalizeRoutedCatalogEntry(
809
809
  delete entry.multi_agent_version;
810
810
  delete entry.multi_agent_reasoning_effort;
811
811
  delete entry.use_responses_lite;
812
+ // Codex 0.164 sends positional `configuration_update` effort items only for rows declaring this
813
+ // and freezes the request-level effort for the context window. A routed provider never
814
+ // negotiated that contract, so a row cloned from a GPT-6 template must not inherit it.
815
+ delete entry.supports_reasoning_effort_updates;
812
816
  delete entry.supports_websockets;
813
817
  // Routed rows cloned from native templates must not inherit OpenAI-only experimental context
814
818
  // delivery. Codex reads the flag as "this model accepts experimental context history" and drives
@@ -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
+ }
@@ -10,17 +10,24 @@ const ENTROPY = new TextEncoder().encode(PURPOSE);
10
10
  // Only the PowerShell fallback is bounded by a clock. A cold Windows PowerShell that must also load
11
11
  // System.Security has been measured past 10s on a loaded machine (Windows CI run 38591626343).
12
12
  const DPAPI_TIMEOUT_MS = 20_000;
13
+ // Cmdlet-free on purpose. The first cmdlet a PowerShell session runs (Add-Type, ConvertFrom-Json)
14
+ // sends it through command discovery and module auto-loading, which without a module-analysis cache
15
+ // for the profile scans the modules on PSModulePath. The test runner gives every run a fresh profile
16
+ // and CI images carry large module sets, so that scan, not DPAPI, is what cold runs waited on.
17
+ // Assembly loading, string splitting and base64 are plain .NET calls outside command discovery.
18
+ // The request is "<operation>\n<base64 data>" on stdin.
13
19
  const SCRIPT = [
14
20
  "$ErrorActionPreference='Stop'",
15
- "Add-Type -AssemblyName System.Security",
16
- "$r=[Console]::In.ReadToEnd()|ConvertFrom-Json",
17
- "$data=[Convert]::FromBase64String($r.data)",
21
+ "[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a')",
22
+ "$r=[Console]::In.ReadToEnd().Split([char]10)",
23
+ "if($r.Length -ne 2){throw 'Invalid request'}",
24
+ "$data=[Convert]::FromBase64String($r[1])",
18
25
  `if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`,
19
26
  `$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`,
20
27
  "$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser",
21
- "if($r.operation -eq 'protect'){",
28
+ "if($r[0] -ceq 'protect'){",
22
29
  " $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)",
23
- "}elseif($r.operation -eq 'unprotect'){",
30
+ "}elseif($r[0] -ceq 'unprotect'){",
24
31
  " $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)",
25
32
  "}else{throw 'Invalid operation'}",
26
33
  "[Console]::Out.Write([Convert]::ToBase64String($out))",
@@ -126,7 +133,7 @@ function runPowerShellDpapi(operation: Operation, data: Uint8Array, timeoutMs =
126
133
  resolve(bytes);
127
134
  });
128
135
  child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ });
129
- child.stdin?.end(JSON.stringify({ operation, data: Buffer.from(data).toString("base64") }));
136
+ child.stdin?.end(`${operation}\n${Buffer.from(data).toString("base64")}`);
130
137
  });
131
138
  }
132
139
 
@@ -163,3 +170,8 @@ export function powerShellAuthorityKeyProtectionForTests(timeoutMs: number): Aut
163
170
  export function usesNativeDpapiForTests(): boolean {
164
171
  return process.platform === "win32" && loadNativeDpapi() !== null;
165
172
  }
173
+
174
+ /** Test-only: the PowerShell helper's script, so its cmdlet-free contract can be checked anywhere. */
175
+ export function powerShellDpapiScriptForTests(): string {
176
+ return SCRIPT;
177
+ }
@@ -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
+ }
@@ -92,6 +92,13 @@ export class CodexQuotaHistory {
92
92
  return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped };
93
93
  }
94
94
 
95
+ /** The newest sample's origin, without pruning or clearing: a passive read changes nothing. */
96
+ peekLatestSource(accountId: string, identity: string | undefined): QuotaHistorySample["source"] | undefined {
97
+ const bucket = this.accounts.get(accountId);
98
+ if (!identity || !bucket || bucket.identity !== identity) return undefined;
99
+ return bucket.samples.at(-1)?.source;
100
+ }
101
+
95
102
  clear(accountId?: string): number {
96
103
  if (accountId === undefined) {
97
104
  const count = this.accounts.size;
@@ -1099,3 +1099,13 @@ export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_
1099
1099
  return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample),
1100
1100
  truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } };
1101
1101
  }
1102
+
1103
+ /**
1104
+ * Where the newest stored reading for a pool account came from (a WHAM usage body or upstream
1105
+ * response headers), or undefined when no current history exists. Read-only: unlike
1106
+ * `getAccountQuotaHistory` it never prunes or clears a mismatched bucket.
1107
+ */
1108
+ export function latestCodexQuotaObservationSource(accountId: string): "wham" | "response-header" | undefined {
1109
+ hydrateAccountQuotasFromDisk();
1110
+ return quotaHistory.peekLatestSource(accountId, poolQuotaHistoryIdentity(accountId));
1111
+ }
@@ -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
  }
@@ -23,7 +23,7 @@ import {
23
23
  failureWindowSteersNewThreads,
24
24
  forgetCodexFailureWindow,
25
25
  forgetCodexFailureWindows,
26
- noteCodexFailureWindowSample,
26
+ noteCodexFailureWindowOutcome,
27
27
  settleCodexSuccessStreak,
28
28
  } from "./routing/failure-window";
29
29
 
@@ -220,6 +220,7 @@ export {
220
220
  isEffectiveCodexAccountPinned,
221
221
  } from "./routing/active-account";
222
222
  export { codexAccountPinDrainReason } from "./routing/pin-drain";
223
+ export { codexFailureWindowOutcomeMeta } from "./routing/failure-window";
223
224
  export type { CodexPinDrainReason } from "./routing/pin-drain";
224
225
 
225
226
  // A shared refresh can outlive the request that opened it. Register the affinity
@@ -1305,7 +1306,7 @@ export function recordCodexUpstreamOutcome(
1305
1306
  */
1306
1307
  dropSpentCredentialFailure(accountId);
1307
1308
  if (outcomeClass === "success") {
1308
- noteCodexFailureWindowSample(config, accountId, false, now);
1309
+ noteCodexFailureWindowOutcome(config, accountId, false, now, meta);
1309
1310
  // The operator's one-shot is spent by a dispatch that actually worked, and only by that.
1310
1311
  // A failed lookup leaves it unspent so the intent survives the failure.
1311
1312
  consumeManualPreference(accountId, codexPoolKeyForScope(quotaScope));
@@ -1555,7 +1556,7 @@ export function recordCodexUpstreamOutcome(
1555
1556
  // A transient failure concludes an owning probe; an unrelated 5xx must not
1556
1557
  // consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
1557
1558
  const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
1558
- noteCodexFailureWindowSample(config, accountId, true, now);
1559
+ noteCodexFailureWindowOutcome(config, accountId, true, now, meta);
1559
1560
  const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
1560
1561
  const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
1561
1562
  // Soft avoid + affinity clears are part of failover. When threshold is 0, leave
@@ -1,5 +1,5 @@
1
1
  import { existsSync, lstatSync, readFileSync } from "node:fs";
2
- import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates";
2
+ import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates";
3
3
  import { readStateResult, stateFiles, statePath } from "./shim-state-file";
4
4
  import { overlayDiagnostic } from "./shim-overlay";
5
5
 
@@ -16,6 +16,7 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
16
16
  const content = readFileSync(path, "utf8");
17
17
  if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false;
18
18
  if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
19
+ if (platform === "win32" && !content.includes(WINDOWS_SHIM_REVISION_MARKER) && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
19
20
  if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false;
20
21
  return true;
21
22
  } catch {
@@ -13,7 +13,7 @@ import {
13
13
  unlinkSync,
14
14
  } from "node:fs";
15
15
  import { posix, win32 } from "node:path";
16
- import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates";
16
+ import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER } from "./shim-templates";
17
17
  import type { ShimFileState } from "./shim-state-file";
18
18
 
19
19
  const CODEX_SHIM_PROBE_BYTES = 16 * 1024;
@@ -188,6 +188,15 @@ function isCurrentUnixShimProbe(probe: StableShimPathProbe): boolean {
188
188
  return probe.prefix.includes(UNIX_SHIM_REVISION_MARKER);
189
189
  }
190
190
 
191
+ /** A Windows wrapper is current when it carries its flavor's revision (CMD/PowerShell, or Git-Bash's Unix one). */
192
+ function isCurrentWindowsShimProbe(probe: StableShimPathProbe): boolean {
193
+ return probe.prefix.includes(WINDOWS_SHIM_REVISION_MARKER) || probe.prefix.includes(UNIX_SHIM_REVISION_MARKER);
194
+ }
195
+
196
+ function isCurrentShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean {
197
+ return platform === "win32" ? isCurrentWindowsShimProbe(probe) : isCurrentUnixShimProbe(probe);
198
+ }
199
+
191
200
  function hasUsableBackingPath(file: ShimFileState): boolean {
192
201
  return [existsSync(file.backupPath) ? file.backupPath : undefined, file.realPath]
193
202
  .some(path => {
@@ -225,4 +234,4 @@ export function isVersionManagerOwnedCodexPath(
225
234
  }
226
235
 
227
236
  export type { ShimPathFingerprint, StableShimPathProbe };
228
- export { statFingerprint, sameFingerprint, sameFingerprintAfterRename, stableShimPathProbe, sameStableShimPathProbe, shimPathFingerprint, restoreWithoutReplacing, isHealthyShimProbe, isCurrentUnixShimProbe, hasUsableBackingPath };
237
+ export { statFingerprint, sameFingerprint, sameFingerprintAfterRename, stableShimPathProbe, sameStableShimPathProbe, shimPathFingerprint, restoreWithoutReplacing, isHealthyShimProbe, isCurrentUnixShimProbe, isCurrentShimProbe, hasUsableBackingPath };