@bitkyc08/opencodex 2.56.0 → 2.57.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 (145) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  8. package/src/adapters/command-code.ts +1 -1
  9. package/src/adapters/cursor/envelope-echo.ts +8 -2
  10. package/src/adapters/google.ts +7 -7
  11. package/src/adapters/kiro/payload.ts +17 -3
  12. package/src/adapters/kiro/reasoning.ts +70 -7
  13. package/src/adapters/kiro/stream.ts +8 -2
  14. package/src/adapters/kiro/wire.ts +2 -1
  15. package/src/adapters/kiro-events.ts +21 -13
  16. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  17. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  18. package/src/adapters/openai-chat.ts +8 -8
  19. package/src/adapters/openai-responses/passthrough.ts +32 -1
  20. package/src/bridge/errors.ts +26 -2
  21. package/src/bridge/response-json.ts +7 -1
  22. package/src/bridge/sse.ts +19 -1
  23. package/src/claude/desktop-profile.ts +66 -9
  24. package/src/claude/outbound.ts +18 -0
  25. package/src/cli/account-main.ts +1 -1
  26. package/src/cli/capabilities.ts +2 -2
  27. package/src/cli/combo.ts +10 -1
  28. package/src/cli/index.ts +48 -5
  29. package/src/cli/registry.ts +2 -1
  30. package/src/cli/system-command.ts +4 -4
  31. package/src/clients/config-export.ts +7 -3
  32. package/src/codex/account-label.ts +14 -3
  33. package/src/codex/account-store.ts +113 -26
  34. package/src/codex/account-usability.ts +21 -0
  35. package/src/codex/auth-api/login-flow.ts +14 -2
  36. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  37. package/src/codex/auth-context.ts +157 -7
  38. package/src/codex/catalog/aggregation.ts +80 -1
  39. package/src/codex/catalog/model-visibility.ts +1 -0
  40. package/src/codex/catalog/remote.ts +30 -0
  41. package/src/codex/catalog/retained-sync.ts +9 -1
  42. package/src/codex/catalog/routed-gather.ts +38 -1
  43. package/src/codex/cli-install-provenance.ts +7 -1
  44. package/src/codex/convergence.ts +7 -2
  45. package/src/codex/desktop-app/types.ts +11 -2
  46. package/src/codex/desktop-app/windows.ts +5 -5
  47. package/src/codex/inject/restore.ts +29 -2
  48. package/src/codex/inject.ts +9 -9
  49. package/src/codex/model-entitlements.ts +152 -15
  50. package/src/codex/pool-refresh-backoff.ts +12 -3
  51. package/src/codex/quota-rejection.ts +104 -15
  52. package/src/codex/routing/cache-affinity.ts +70 -0
  53. package/src/codex/routing/cooldown-math.ts +10 -0
  54. package/src/codex/routing/selection.ts +79 -2
  55. package/src/codex/routing/thread-affinity.ts +50 -2
  56. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  57. package/src/codex/routing.ts +29 -49
  58. package/src/codex/warmup.ts +1 -1
  59. package/src/combos/failover.ts +85 -0
  60. package/src/combos/request.ts +17 -10
  61. package/src/combos/types.ts +23 -2
  62. package/src/config/pending-teardown.ts +31 -0
  63. package/src/generated/compatibility-version.json +163 -135
  64. package/src/images/loop.ts +1 -1
  65. package/src/lib/errors.ts +17 -0
  66. package/src/lib/request-execution-budget.ts +147 -21
  67. package/src/lib/spend-reservation-ledger.ts +18 -0
  68. package/src/lib/state-store-registrations.ts +6 -2
  69. package/src/lib/test-home-guard.ts +85 -1
  70. package/src/lib/upstream-retry.ts +77 -10
  71. package/src/lib/windows-elevation.ts +76 -14
  72. package/src/oauth/index.ts +2 -2
  73. package/src/oauth/key-providers.ts +2 -2
  74. package/src/providers/kiro-models.ts +4 -3
  75. package/src/providers/label.ts +19 -1
  76. package/src/providers/model-discovery.ts +16 -0
  77. package/src/providers/registry/entries-core.ts +7 -0
  78. package/src/providers/registry/entries-extended.ts +9 -0
  79. package/src/providers/registry/model-seeds.ts +4 -0
  80. package/src/responses/reasoning-envelope.ts +6 -3
  81. package/src/routing/identity-domains.ts +21 -14
  82. package/src/routing/probe-lease.ts +103 -1
  83. package/src/server/chat-completions.ts +3 -1
  84. package/src/server/chat-native.ts +37 -9
  85. package/src/server/index/live-sideband.ts +37 -1
  86. package/src/server/index/websocket-handler.ts +6 -2
  87. package/src/server/index.ts +5 -5
  88. package/src/server/inspection-tee.ts +107 -0
  89. package/src/server/live.ts +46 -1
  90. package/src/server/management/combo-routes.ts +10 -1
  91. package/src/server/relay-eager.ts +2 -0
  92. package/src/server/relay.ts +14 -19
  93. package/src/server/request-log.ts +127 -3
  94. package/src/server/response-log-body.ts +153 -0
  95. package/src/server/responses/account-change-state.ts +74 -0
  96. package/src/server/responses/adapter-continuation.ts +33 -7
  97. package/src/server/responses/adapter-delivery.ts +5 -11
  98. package/src/server/responses/adapter-dispatch.ts +84 -13
  99. package/src/server/responses/codex-ws-wire.ts +5 -0
  100. package/src/server/responses/collaboration.ts +74 -4
  101. package/src/server/responses/combo-session-recall.ts +68 -8
  102. package/src/server/responses/compact.ts +54 -13
  103. package/src/server/responses/core-auth.ts +2 -0
  104. package/src/server/responses/core-codex-account.ts +51 -3
  105. package/src/server/responses/core-combo.ts +103 -23
  106. package/src/server/responses/core-errors.ts +18 -0
  107. package/src/server/responses/core-replay.ts +105 -32
  108. package/src/server/responses/core.ts +3 -3
  109. package/src/server/responses/encrypted-payload.ts +0 -1
  110. package/src/server/responses/input-admission.ts +126 -6
  111. package/src/server/responses/passthrough-delivery.ts +19 -6
  112. package/src/server/responses/passthrough-dispatch.ts +28 -10
  113. package/src/server/responses/passthrough-error.ts +38 -2
  114. package/src/server/responses/request-prepare.ts +132 -22
  115. package/src/server/responses/request-send-budget.ts +97 -2
  116. package/src/server/responses/request-spend.ts +147 -0
  117. package/src/server/responses/request-transport.ts +62 -3
  118. package/src/server/responses/run-turn-execution.ts +59 -31
  119. package/src/server/responses/sidecar-execution.ts +7 -13
  120. package/src/server/responses/terminal-guard.ts +65 -4
  121. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  122. package/src/service/windows-ops.ts +210 -16
  123. package/src/service/windows-scheduler.ts +28 -21
  124. package/src/service.ts +1 -1
  125. package/src/types/config.ts +4 -1
  126. package/src/types/request.ts +8 -5
  127. package/src/types/tools.ts +24 -0
  128. package/src/types.ts +2 -0
  129. package/src/update/index.ts +10 -0
  130. package/src/update/stop-contract.d.mts +1 -0
  131. package/src/update/stop-contract.mjs +19 -0
  132. package/src/update/stop-decision.d.mts +1 -1
  133. package/src/update/stop-decision.mjs +12 -3
  134. package/src/usage/log.ts +1 -1
  135. package/src/vision/anthropic-describe.ts +1 -1
  136. package/src/vision/describe.ts +5 -5
  137. package/src/web-search/anthropic-executor.ts +1 -1
  138. package/src/web-search/exa-executor.ts +1 -1
  139. package/src/web-search/executor.ts +1 -1
  140. package/src/web-search/gemini-executor.ts +1 -1
  141. package/src/web-search/loop.ts +1 -1
  142. package/src/web-search/ollama-executor.ts +1 -1
  143. package/src/web-search/parse.ts +67 -14
  144. package/src/web-search/passthrough-bridge.ts +64 -31
  145. package/src/web-search/xai-executor.ts +1 -1
@@ -39,7 +39,12 @@ import {
39
39
  pickAlternateCodexAccount,
40
40
  resolveCodexAccountForThreadDetailed,
41
41
  type CodexAffinityDecision,
42
+ type CodexThreadResolution,
43
+ type TransientProbeGrant,
42
44
  } from "./routing";
45
+ // The half-open TRANSIENT-HOLD lease (#4701). Not the quota-cooldown probe lease imported from
46
+ // ./routing above -- different module, different domain, and a request never holds both.
47
+ import { releaseTransientProbe } from "../routing/probe-lease";
43
48
  import {
44
49
  codexConversationIdentity,
45
50
  recordCodexThreadLineage,
@@ -47,6 +52,7 @@ import {
47
52
  type CodexThreadLineage,
48
53
  } from "./lineage";
49
54
  import {
55
+ cachedDeniedCodexAccountIdsForModel,
50
56
  entitledCodexAccountIdsForModel,
51
57
  isDirectCallerEntitledToCodexModel,
52
58
  resolveCodexModelEntitlements,
@@ -202,6 +208,12 @@ export type CodexAuthContext =
202
208
  affinityDecision?: CodexAffinityDecision;
203
209
  /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
204
210
  probeQuotaScope?: CodexQuotaScope;
211
+ /**
212
+ * Set when this request is the ONE dispatch admitted to test an account held under a
213
+ * transient 5xx hold (#4701). Echo it into the upstream outcome so the trial is settled
214
+ * by the request that ran it, and release it on any path that never reaches upstream.
215
+ */
216
+ transientProbe?: TransientProbeGrant;
205
217
  }
206
218
  | {
207
219
  // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
@@ -222,6 +234,8 @@ export type CodexAuthContext =
222
234
  probeLeaseId?: string;
223
235
  quotaScope?: CodexQuotaScope;
224
236
  probeQuotaScope?: CodexQuotaScope;
237
+ /** See `pool.transientProbe`. */
238
+ transientProbe?: TransientProbeGrant;
225
239
  };
226
240
 
227
241
  /** Probe lease carried by this context, when it holds one. */
@@ -234,11 +248,24 @@ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQu
234
248
  return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
235
249
  }
236
250
 
251
+ /** The transient-hold recovery probe carried by this context, when it holds one (#4701). */
252
+ export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): TransientProbeGrant | undefined {
253
+ return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.transientProbe : undefined;
254
+ }
255
+
237
256
  /**
238
257
  * Hand back a probe lease for a request that will not reach upstream. Safe to
239
258
  * call with a context that holds no lease.
259
+ *
260
+ * BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold
261
+ * probe, and every one of the ~30 call sites that already hands back the first is a path where
262
+ * the second would leak too. Releasing them together is what makes those sites correct for the
263
+ * new lease without re-deriving the discard set by hand -- the failure mode being avoided is a
264
+ * held account nobody may probe because the request that held the trial went away quietly.
240
265
  */
241
266
  export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
267
+ const transientProbe = codexTransientProbeGrant(ctx);
268
+ if (transientProbe) releaseTransientProbe(transientProbe.lease);
242
269
  const leaseId = codexProbeLeaseId(ctx);
243
270
  if (!ctx || ctx.kind === "main" || !leaseId) return;
244
271
  if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
@@ -424,6 +451,44 @@ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableE
424
451
  }
425
452
  }
426
453
 
454
+ /**
455
+ * Every account bound to this conversation is held after upstream failures, the recovery
456
+ * budget for this window is spent, and there is no detour left -- so this request is refused
457
+ * BEFORE any upstream I/O (#4701).
458
+ *
459
+ * This is not a quota cooldown, and the message below says so. It subclasses
460
+ * {@link CodexAccountCooldownError} for one reason: the deadline-carrying refusal has exactly
461
+ * one representation in this codebase, and roughly a dozen transports already map it to a 429
462
+ * with `Retry-After` and treat it as an expected terminal answer rather than a credential
463
+ * fault. Introducing a parallel type would mean either re-deriving that handling in every one
464
+ * of them or silently falling through to a 500 in the ones that were missed.
465
+ *
466
+ * What must NOT be inherited is the quota wording -- "cooling down", `ocx account
467
+ * clear-cooldown` -- because none of it describes a 5xx hold and following it would do
468
+ * nothing. {@link cooldownErrorMessage} therefore returns this class's own message verbatim,
469
+ * the same escape hatch {@link CodexMainAccountHardLockError} and
470
+ * {@link CodexReserveUnavailableError} already use.
471
+ *
472
+ * `cooldownUntil` carries the limiter's own change point, which is strictly in the future:
473
+ * either the moment the held account may next be probed or the moment the recovery window
474
+ * moves, whichever is later. A refusal that answered `now` would busy-loop the caller into
475
+ * the same load it just declined.
476
+ */
477
+ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
478
+ /** The sibling still remembered for this thread, when one exists but is itself unusable. */
479
+ readonly detourAccountId?: string;
480
+
481
+ constructor(accountId: string, retryAt: number, detourAccountId?: string) {
482
+ super(accountId, retryAt);
483
+ this.name = "CodexRecoveryWithheldError";
484
+ this.detourAccountId = detourAccountId;
485
+ this.message = `Codex account (${cooldownAccountLabel(accountId)}) is held after repeated upstream`
486
+ + ` failures and the pool's recovery budget for this window is spent, so nothing was sent`
487
+ + ` upstream. Retry after ${new Date(retryAt).toISOString()}.`
488
+ + " This clears on its own as the account recovers; no cooldown to lift and no account to switch.";
489
+ }
490
+ }
491
+
427
492
  export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
428
493
  "codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
429
494
  >>;
@@ -634,7 +699,12 @@ export function cooldownAccountLabel(accountId: string): string {
634
699
  * injected `openai_base_url` in config.toml.
635
700
  */
636
701
  export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
637
- if (err instanceof CodexMainAccountHardLockError || err instanceof CodexReserveUnavailableError) return err.message;
702
+ if (err instanceof CodexMainAccountHardLockError
703
+ || err instanceof CodexReserveUnavailableError
704
+ // A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
705
+ // one, and the quota recovery advice below would send the operator after a cooldown that
706
+ // does not exist (#4701).
707
+ || err instanceof CodexRecoveryWithheldError) return err.message;
638
708
  const until = new Date(err.cooldownUntil).toISOString();
639
709
  const scopeLabels: Record<CodexQuotaScope, string> = {
640
710
  shared: "shared native quota", reserve: "Reserve quota",
@@ -717,6 +787,11 @@ export interface ResolveCodexAuthContextOptions {
717
787
  requestScopedMainCredential?: boolean;
718
788
  /** Test seam for a Direct request's own forwarded ChatGPT credential. */
719
789
  isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
790
+ /**
791
+ * This request's conversation carries live uploaded-file references (#4778). Retains the bound
792
+ * account across a VOLUNTARY quota move; involuntary release is untouched.
793
+ */
794
+ retainAccountForUploadedFiles?: boolean;
720
795
  }
721
796
 
722
797
  export interface CodexAccountSelectionAdmission {
@@ -870,6 +945,15 @@ export async function resolveCodexAuthContext(
870
945
  // Why this request is on this account, carried to the request log so a move reads as an event
871
946
  // instead of something inferred from account labels across lines (#4546).
872
947
  let affinityDecision: CodexAffinityDecision | undefined;
948
+ // The half-open trial this request was granted, if it is the one allowed to test a held
949
+ // account. Declared out here because the release paths below and the returned context are on
950
+ // opposite sides of several throws (#4701).
951
+ let transientProbe: TransientProbeGrant | undefined;
952
+ const releaseTransientProbeGrant = (): void => {
953
+ if (!transientProbe) return;
954
+ releaseTransientProbe(transientProbe.lease);
955
+ transientProbe = undefined;
956
+ };
873
957
  // Retained startup recovery makes the physical main identity ineligible. Routing
874
958
  // can still preserve service by selecting a healthy configured pool account. A
875
959
  // request-owned bearer likewise cannot inspect or reconcile file-main state.
@@ -900,6 +984,23 @@ export async function resolveCodexAuthContext(
900
984
  const modelEligibleAccountIds = entitledAccountIds
901
985
  ? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate)))
902
986
  : undefined;
987
+ // #4768: the flagships stay visible and never fail closed, so this is evidence routing may
988
+ // ORDER by, not evidence it may refuse on. Read synchronously from rosters discovery has
989
+ // already gathered -- no upstream fetch joins the request path for the most commonly
990
+ // requested models in the product -- and passed to selection as a preference that is dropped
991
+ // whenever honouring it would leave no candidate.
992
+ //
993
+ // Under the SAME exclusion the entitlement snapshot above uses. The reader validates each
994
+ // cached roster against the account's current credential, and for native main that is a
995
+ // synchronous read of the physical stored token -- exactly what this request is forbidden to
996
+ // touch while a profile switch drains it or while it is served by a request-owned credential.
997
+ // Excluding main here costs nothing: the preference is an ordering hint, so main becomes
998
+ // unknown rather than denied, and unknown leaves selection exactly as it was.
999
+ const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel(
1000
+ options.modelId,
1001
+ undefined,
1002
+ { excludeAccountIds },
1003
+ );
903
1004
  const selectionOptions = {
904
1005
  // Temporary switch drain keeps the candidate until the atomic claim rejects
905
1006
  // it. Retained recovery makes main wholly ineligible so pool routing continues.
@@ -911,13 +1012,21 @@ export async function resolveCodexAuthContext(
911
1012
  ? () => preserveRequestOwnedMainPin
912
1013
  : options.isMainAccountTokenLive,
913
1014
  modelEligibleAccountIds,
1015
+ deniedModelAccountIds,
1016
+ // Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one
1017
+ // conversation's attachments say nothing about where unrelated threads should be served.
1018
+ retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true,
914
1019
  };
915
1020
  // A pre-drain selector reserves the native identity while reconciliation and
916
1021
  // routing inspect it. Selectors arriving after the fence skip reconciliation
917
1022
  // and may still route to non-main pool accounts without touching switch state.
918
1023
  if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError();
919
1024
  if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
920
- const resolution = fixedAccountId !== undefined
1025
+ // Annotated, not inferred. The two literals below carry neither `affinity` nor
1026
+ // `transientProbe`, so an inferred union makes `"k" in resolution` widen those reads to
1027
+ // `unknown` and a discriminant narrowing fail outright. Contextually typing every branch to
1028
+ // the resolver's own union is what lets the reads below stay total.
1029
+ const resolution: CodexThreadResolution = fixedAccountId !== undefined
921
1030
  ? { status: "selected" as const, accountId: fixedAccountId }
922
1031
  : options.excludeAccountId
923
1032
  ? (() => {
@@ -942,8 +1051,17 @@ export async function resolveCodexAuthContext(
942
1051
  lineage,
943
1052
  );
944
1053
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
1054
+ // THE REFUSAL. Every candidate is held, the recovery budget is spent, and no detour is
1055
+ // left -- so this request must not reach upstream at all. Returning the held account here
1056
+ // is what #4701 is about: under a provider-wide 503 that is every bound request piling
1057
+ // onto an account already known to be failing. Thrown before any credential is read, so
1058
+ // nothing is sent and nothing is spent.
1059
+ if (resolution.status === "withheld") {
1060
+ throw new CodexRecoveryWithheldError(resolution.accountId, resolution.retryAt, resolution.detourAccountId);
1061
+ }
945
1062
  const selected = resolution.status === "selected" ? resolution.accountId : null;
946
- affinityDecision = "affinity" in resolution ? resolution.affinity : undefined;
1063
+ affinityDecision = resolution.affinity;
1064
+ transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined;
947
1065
  if (!selected) {
948
1066
  // A retry that excluded a failed Pool account may still use the validated caller-owned
949
1067
  // main credential. Treating every exclusion as if main itself had failed strands a healthy
@@ -1025,12 +1143,25 @@ export async function resolveCodexAuthContext(
1025
1143
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
1026
1144
  }
1027
1145
  }
1146
+ } catch (cause) {
1147
+ // Selection granted a trial and then a later policy check refused the account. The trial
1148
+ // never runs, so hand it back instead of leaving the held account unprobeable until its
1149
+ // deadline lapses (#4701).
1150
+ releaseTransientProbeGrant();
1151
+ throw cause;
1028
1152
  } finally {
1029
1153
  selectionAdmission?.release();
1030
1154
  }
1031
1155
  // Legacy selectors may retain an unusable account for actionable errors. A
1032
1156
  // deferred credential must never become request auth through that fallback.
1033
- assertCodexAccountValidationReady(accountId);
1157
+ try {
1158
+ assertCodexAccountValidationReady(accountId);
1159
+ } catch (cause) {
1160
+ // Nothing will reach upstream, so give the trial back instead of leaving the held account
1161
+ // unprobeable until the lease deadline lapses (#4701).
1162
+ releaseTransientProbeGrant();
1163
+ throw cause;
1164
+ }
1034
1165
  // Lazy prime: if the selected account has no quota yet, the pool is likely
1035
1166
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
1036
1167
  // best-effort prime so the NEXT routing decision has real scores. This never
@@ -1049,6 +1180,13 @@ export async function resolveCodexAuthContext(
1049
1180
  // a literal Retry-After reads very differently to a user than a reset-derived guess.
1050
1181
  const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
1051
1182
  const cooldownUntil = cooldown?.cooldownUntil;
1183
+ // A transient-hold trial and a quota cooldown cannot both describe this account:
1184
+ // `isTransientOnlyAffinityBlock` refuses to recognise a transient hold on an account carrying
1185
+ // quota health, so the cooldown branch below is unreachable while a trial is held. That is
1186
+ // also why no request pays two recovery permits for one send. The release is defensive --
1187
+ // should that invariant ever move, the trial is handed back rather than stranded behind a
1188
+ // refusal that belongs to the other domain.
1189
+ if (cooldownUntil && transientProbe) releaseTransientProbeGrant();
1052
1190
  // A cooled-down account never sends traffic, so upstream recovery can never be
1053
1191
  // observed and the cooldown outlives the real limit. Admit one probe per
1054
1192
  // interval; its outcome decides whether the cooldown ends (#433).
@@ -1089,6 +1227,7 @@ export async function resolveCodexAuthContext(
1089
1227
  if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter);
1090
1228
  assertMainAccountPolicy(policy);
1091
1229
  } catch (cause) {
1230
+ releaseTransientProbeGrant();
1092
1231
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1093
1232
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1094
1233
  if (cause instanceof CodexMainAccountHardLockError) throw cause;
@@ -1099,15 +1238,23 @@ export async function resolveCodexAuthContext(
1099
1238
  }
1100
1239
  if (!token) {
1101
1240
  // Nothing will reach upstream, so give the probe back instead of burning it.
1241
+ releaseTransientProbeGrant();
1102
1242
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1103
1243
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1104
1244
  throw new CodexPoolAuthenticationError(
1105
1245
  fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
1106
1246
  );
1107
1247
  }
1108
- const reserveAuthorization = reserve
1109
- ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
1110
- : undefined;
1248
+ let reserveAuthorization: MainReserveAuthorization | undefined;
1249
+ try {
1250
+ reserveAuthorization = reserve
1251
+ ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
1252
+ : undefined;
1253
+ } catch (cause) {
1254
+ // A Reserve refusal ends the request here, so the trial it was holding never runs.
1255
+ releaseTransientProbeGrant();
1256
+ throw cause;
1257
+ }
1111
1258
  return {
1112
1259
  kind: "main-pool",
1113
1260
  accountId,
@@ -1121,6 +1268,7 @@ export async function resolveCodexAuthContext(
1121
1268
  ...(quotaScope ? { quotaScope } : {}),
1122
1269
  ...(probeLeaseId ? { probeLeaseId } : {}),
1123
1270
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1271
+ ...(transientProbe ? { transientProbe } : {}),
1124
1272
  };
1125
1273
  }
1126
1274
 
@@ -1141,8 +1289,10 @@ export async function resolveCodexAuthContext(
1141
1289
  ...(probeLeaseId ? { probeLeaseId } : {}),
1142
1290
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1143
1291
  ...(affinityDecision ? { affinityDecision } : {}),
1292
+ ...(transientProbe ? { transientProbe } : {}),
1144
1293
  };
1145
1294
  } catch (cause) {
1295
+ releaseTransientProbeGrant();
1146
1296
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1147
1297
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1148
1298
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
@@ -34,7 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
34
34
 
35
35
 
36
36
  import { catalogModelSlug } from "./parsing";
37
- import type { CatalogModel } from "./parsing";
37
+ import type { CatalogModel, RawEntry } from "./parsing";
38
38
 
39
39
  export const openAiApiCollisionWarnings = new Set<string>();
40
40
 
@@ -222,6 +222,85 @@ export function safeCatalogWarningLabel(value: string): string {
222
222
  .slice(0, 200);
223
223
  }
224
224
 
225
+ /**
226
+ * Keep the first row of each slug and drop the rest (#4730).
227
+ *
228
+ * First-win is the only answer that agrees with the ordering already decided upstream: the merge
229
+ * ranks rows, so its first occurrence is the row it chose. Distinct slugs are never touched — an
230
+ * alias row and the canonical routed row of the same provider model are two different public names
231
+ * and both survive — and a row without a string slug passes through untouched.
232
+ */
233
+ export function dedupeCatalogEntriesBySlug(models: RawEntry[]): RawEntry[] {
234
+ const seen = new Set<string>();
235
+ const out: RawEntry[] = [];
236
+ for (const entry of models) {
237
+ if (typeof entry.slug !== "string") {
238
+ out.push(entry);
239
+ continue;
240
+ }
241
+ if (seen.has(entry.slug)) continue;
242
+ seen.add(entry.slug);
243
+ out.push(entry);
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * Every slug this proxy writes into the Codex catalog must appear exactly once (#4730).
250
+ *
251
+ * The cost of breaking it is the whole file: a slug-unique validating consumer refuses the catalog
252
+ * outright, so one duplicated row takes every model with it. A 2.56.0 report carried 507 rows for
253
+ * 72 unique slugs, every duplicate byte-identical, and Codex rejected the file as `source-invalid`.
254
+ *
255
+ * This is a write-boundary invariant rather than a repair of one producer, and that distinction is
256
+ * deliberate: the reported catalog is evidence that some emit path can double a row, but nothing in
257
+ * this tree has been shown to be that path, and a guard that only covered the producer someone
258
+ * guessed at would leave the file corruptible by the next one. Both writers that serialize a merged
259
+ * catalog call this as their LAST mutation — `writeRetainedCatalogSync` and the management
260
+ * convergence commit — so uniqueness holds for the exact bytes that land on disk.
261
+ *
262
+ * Ordering is load-bearing. Running the guard before the effort clamp would be unsound:
263
+ * `clampCatalogModelsToObservedCodexSupport` splices whole rows out when an exact-reserve ladder
264
+ * clamps empty, so dropping a later same-slug row first can leave the slug with no row at all once
265
+ * the surviving one is spliced.
266
+ *
267
+ * @param models - The finished row list, already clamped and finalized.
268
+ * @param warn - Whether to report on `console.warn`. The convergence path merges under
269
+ * `warningPolicy: "suppress"` and stays silent for the same reason.
270
+ * @returns The original array when it was already unique, so an unchanged catalog stays a no-op
271
+ * write; otherwise a first-win copy.
272
+ */
273
+ export function enforceCatalogSlugUniqueness(models: RawEntry[], warn: boolean): RawEntry[] {
274
+ const deduped = dedupeCatalogEntriesBySlug(models);
275
+ if (deduped.length === models.length) return models;
276
+ if (warn) {
277
+ // A dropped row that differs from the kept one means two emit paths disagree about the same
278
+ // slug's content. First-win still stands, but the operator needs to see WHICH slugs diverged
279
+ // instead of silently losing data. The baseline is the row the dedupe actually keeps — the
280
+ // FIRST occurrence — so the reported divergence is measured against what lands on disk.
281
+ const keptBySlug = new Map<string, RawEntry>();
282
+ for (const entry of models) {
283
+ if (typeof entry.slug !== "string" || keptBySlug.has(entry.slug)) continue;
284
+ keptBySlug.set(entry.slug, entry);
285
+ }
286
+ const divergentSlugs = new Set<string>();
287
+ for (const entry of models) {
288
+ if (typeof entry.slug !== "string") continue;
289
+ const kept = keptBySlug.get(entry.slug);
290
+ if (kept && kept !== entry && JSON.stringify(kept) !== JSON.stringify(entry)) {
291
+ divergentSlugs.add(entry.slug);
292
+ }
293
+ }
294
+ const divergentNote = divergentSlugs.size > 0
295
+ ? `; divergent content on: ${[...divergentSlugs].slice(0, 5).map(safeCatalogWarningLabel).join(", ")}${divergentSlugs.size > 5 ? ", …" : ""}`
296
+ : "";
297
+ console.warn(
298
+ `[opencodex] catalog sync dropped ${models.length - deduped.length} duplicate slug row(s), keeping the first occurrence of each slug (#4730)${divergentNote}.`,
299
+ );
300
+ }
301
+ return deduped;
302
+ }
303
+
225
304
  export function comboCatalogWarningSignature(
226
305
  combo: NormalizedComboConfig,
227
306
  members: readonly CatalogModel[],
@@ -290,6 +290,7 @@ export function filterCatalogVisibleModels(
290
290
  }
291
291
  return models.filter(m => {
292
292
  if (initialModelSelectionPending(config.providers[m.provider])) return false;
293
+ if (config.providers[m.provider]?.disabled === true) return false;
293
294
  const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true;
294
295
  // disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
295
296
  for (const stored of disabled) {
@@ -113,12 +113,42 @@ function safeTimeout(value: number | undefined): number {
113
113
  ? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS;
114
114
  }
115
115
 
116
+ /** Match Bun fetch's environment routing, not the broader WebSocket NO_PROXY grammar. */
117
+ function catalogRequestUsesBunHttpProxy(url: URL): boolean {
118
+ if (url.protocol !== "http:") return false;
119
+ const proxy = process.env.http_proxy || process.env.HTTP_PROXY;
120
+ if (!proxy || proxy === '""' || proxy === "''") return false;
121
+ const hostname = url.hostname.toLowerCase();
122
+ const host = url.host.toLowerCase();
123
+ // Bun env_loader::is_no_proxy (1.4.2): lowercase wins unless empty, ASCII
124
+ // whitespace only, no scheme/path/wildcard/bracket/trailing-dot normalization.
125
+ const bypasses = process.env.no_proxy || process.env.NO_PROXY || "";
126
+ for (let entry of bypasses.split(",")) {
127
+ entry = entry.replace(/^[ \t\n\r\v\f]+|[ \t\n\r\v\f]+$/g, "")
128
+ .replace(/[A-Z]/g, letter => letter.toLowerCase());
129
+ if (entry === "*") return false;
130
+ if (entry.startsWith(".")) entry = entry.slice(1);
131
+ if (!entry) continue;
132
+ const hasPort = entry.startsWith("[")
133
+ ? entry.includes("]:")
134
+ : (entry.match(/:/g)?.length ?? 0) === 1;
135
+ if (hasPort ? host === entry : hostname === entry || hostname.endsWith(`.${entry}`)) return false;
136
+ }
137
+ return true;
138
+ }
139
+
116
140
  export async function fetchRemoteCatalog(
117
141
  input: string,
118
142
  options: Pick<PullRemoteCatalogOptions, "token" | "timeoutMs" | "maxBytes" | "fetchImpl"> = {},
119
143
  ): Promise<{ document: RemoteCatalogDocument; content: string }> {
120
144
  const url = validateRemoteCatalogUrl(input);
121
145
  const token = validateToken(options.token);
146
+ if (catalogRequestUsesBunHttpProxy(url)) {
147
+ throw new RemoteCatalogError(
148
+ "insecure_http_refused",
149
+ "Loopback HTTP catalog requests must bypass outbound HTTP proxy routing",
150
+ );
151
+ }
122
152
  const headers = new Headers({ Accept: "application/json" });
123
153
  if (token !== undefined) headers.set("Authorization", `Bearer ${token}`);
124
154
  let response: Response;
@@ -51,7 +51,7 @@ import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled";
51
51
  import { isMultiAgentV2Enabled } from "../features";
52
52
  import { clampCatalogModelsToCodexSupport } from "./effort";
53
53
  import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch";
54
- import { exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
54
+ import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
55
55
  import {
56
56
  withCatalogWriteSerialization,
57
57
  type CatalogWritePermit,
@@ -522,6 +522,9 @@ function writeRetainedCatalogSync({
522
522
  });
523
523
  clampCatalogModelsToCodexSupport(catalog.models);
524
524
  finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config);
525
+ // Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering
526
+ // against the effort clamp is load-bearing rather than cosmetic.
527
+ catalog.models = enforceCatalogSlugUniqueness(catalog.models, true);
525
528
 
526
529
  const added = goEntries.length + accountBoundEntries.length;
527
530
  const content = `${JSON.stringify(catalog, null, 2)}\n`;
@@ -553,6 +556,11 @@ function writeRetainedCatalogSync({
553
556
  };
554
557
  }
555
558
 
559
+ // Re-exported so the #4730 unit regression keeps importing the guard from the sync module it
560
+ // guards; the implementation lives in ./aggregation because the management convergence commit
561
+ // is the second writer that has to apply the identical rule.
562
+ export { dedupeCatalogEntriesBySlug };
563
+
556
564
  export async function syncCatalogModels(
557
565
  config: OcxConfig,
558
566
  options?: CodexCatalogSyncOptions,
@@ -420,6 +420,42 @@ async function gatherRoutedModelsUncached(
420
420
  if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
421
421
  }
422
422
  }
423
+ // [Decision Log]
424
+ // - 목적과 의도: combo derivation must see the same explicit custom-model capabilities that the
425
+ // final Models inventory publishes. Previously customModels were materialized only after this
426
+ // map had already derived every combo, so one row could say image while its combo said text.
427
+ // - 기존 구현 및 제약 조건: provider/discovery rows remain the inheritance source, and native
428
+ // OpenAI synthesis must run first so a sparse custom row cannot hide native hard limits.
429
+ // - 검토한 주요 대안: move the full custom-row materializer ahead of combos, or overlay only the
430
+ // explicit custom fields onto this private derivation map after provider/native inheritance.
431
+ // - 선택한 방식: use the scoped post-inheritance overlay; the existing final materializer stays
432
+ // the single owner of public custom-row construction and deduplication.
433
+ // - 다른 대안 대신 이 방식을 선택한 이유: moving the large materializer would reorder public
434
+ // catalog production and warning behavior, while this map is already private to combo input.
435
+ // - 장점, 단점 및 영향: custom context/modality/reasoning/tool-mode declarations now constrain
436
+ // their combos without widening unrelated rows; omitted fields retain provider/native limits.
437
+ for (const custom of config.customModels ?? []) {
438
+ const key = `${custom.provider}/${custom.modelId}`;
439
+ const inherited = memberByKey.get(key) ?? {
440
+ provider: custom.provider,
441
+ id: custom.modelId,
442
+ owned_by: custom.provider,
443
+ };
444
+ memberByKey.set(key, {
445
+ ...inherited,
446
+ catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND,
447
+ ...(typeof custom.contextWindow === "number" && custom.contextWindow > 0
448
+ ? { contextWindow: custom.contextWindow }
449
+ : {}),
450
+ ...(Array.isArray(custom.inputModalities)
451
+ ? { inputModalities: [...custom.inputModalities] }
452
+ : {}),
453
+ ...(Array.isArray(custom.reasoningEfforts)
454
+ ? { reasoningEfforts: [...custom.reasoningEfforts] }
455
+ : {}),
456
+ ...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}),
457
+ });
458
+ }
423
459
  // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and
424
460
  // custom-model vision-sidecar inheritance so both see the same merged registry view.
425
461
  const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider]));
@@ -480,7 +516,8 @@ async function gatherRoutedModelsUncached(
480
516
  // with the same slug below, so that row's provider capability metadata is the inheritance source.
481
517
  const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
482
518
  const customModels = (config.customModels ?? []).map(cm => {
483
- const rawProvider = config.providers[cm.provider];
519
+ const rawProvider = config.providers[cm.provider]?.disabled !== true
520
+ ? config.providers[cm.provider] : undefined;
484
521
  const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider;
485
522
  // Registry routing backfills an omitted authMode on the built-in OpenAI provider to
486
523
  // forward. Keep the catalog projection on the same contract while still failing closed
@@ -587,8 +587,14 @@ export async function inspectCodexCliInstall(
587
587
  const platform = deps.platform ?? process.platform;
588
588
  const candidate = observeCodexRuntimeCandidateReadOnly(deps);
589
589
  if (!candidate) {
590
+ // This slice reads no candidate or configuration file on Windows, so an
591
+ // absent proof-captured environment candidate does not establish that no
592
+ // Codex CLI exists: a persisted selection is simply never consulted there.
593
+ // Report the deferral that actually happened instead of the stronger claim
594
+ // that the candidate is unavailable. POSIX retains its existing result
595
+ // when no candidate is observed.
590
596
  return isWindowsPlatform(platform)
591
- ? unknownWindowsReport("candidate_unavailable")
597
+ ? unknownWindowsReport("windows_inspection_deferred")
592
598
  : unknownReport("candidate_unavailable");
593
599
  }
594
600
 
@@ -49,7 +49,7 @@ import {
49
49
  orderForSubagents,
50
50
  } from "./catalog/sync";
51
51
  import { multiAgentV2EnabledFromConfigText } from "./features";
52
- import { exactComboCatalogSlugs } from "./catalog/aggregation";
52
+ import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation";
53
53
  import {
54
54
  isNativeAliasCatalogEntry,
55
55
  accountBoundNativeOpenAiSlugs,
@@ -386,7 +386,12 @@ function prepareCatalog(
386
386
  : null,
387
387
  );
388
388
  finalizeAutoReviewModelOverride(mergedModels, catalogModels, config);
389
- catalog.models = mergedModels;
389
+ // The second writer of this file. A dashboard model toggle, a combo edit, or a Codex account
390
+ // login reaches `convergeCodexCatalog` and commits through `fixedCommit`, never through
391
+ // `writeRetainedCatalogSync`, so the #4730 uniqueness guard has to stand here too or the same
392
+ // `source-invalid` rejection returns by a different route. Silent because this merge runs under
393
+ // `warningPolicy: "suppress"`.
394
+ catalog.models = enforceCatalogSlugUniqueness(mergedModels, false);
390
395
  return catalog;
391
396
  }
392
397
 
@@ -116,11 +116,20 @@ export interface DesktopAppAdapter {
116
116
  *
117
117
  * `root` is expected to be `realpath`-resolved by discovery already.
118
118
  */
119
+ function isMembershipSeparator(character: string): boolean {
120
+ // `/` separates on every platform this runs on, and Windows accepts it wherever it
121
+ // accepts `\`. `\` is only a separator where the host says so: it is a legal
122
+ // FILENAME character on POSIX, so admitting it there would reopen the sibling hole
123
+ // this function exists to close.
124
+ return character === "/" || (sep === "\\" && character === "\\");
125
+ }
126
+
119
127
  export function isUnderRoot(executable: string, root: string): boolean {
120
128
  if (!executable || !root) return false;
121
129
  if (executable === root) return true;
122
- const prefix = root.endsWith(sep) ? root : root + sep;
123
- return executable.startsWith(prefix);
130
+ if (!executable.startsWith(root)) return false;
131
+ if (isMembershipSeparator(root[root.length - 1]!)) return true;
132
+ return isMembershipSeparator(executable[root.length] ?? "");
124
133
  }
125
134
 
126
135
  /**
@@ -29,16 +29,16 @@ const SHELL_BASENAME = "chatgpt.exe";
29
29
  const POWERSHELL_PROBE_OPTIONS = { timeout: PROBE_TIMEOUT_MS, windowsHide: true } as const;
30
30
 
31
31
  /**
32
- * isUnderRoot prefixes with the host path.sep and is case-sensitive. Windows
32
+ * isUnderRoot checks a lexical path boundary and is case-sensitive. Windows
33
33
  * membership is case-insensitive, and this file is executed by Unix CI against
34
- * backslash paths, so both sides are folded onto the host separator first.
34
+ * mixed slash paths, so both slash forms are folded onto the host separator first.
35
35
  * The boundary itself — sibling `OpenAI.Codex-evil` must not match root
36
36
  * `OpenAI.Codex` — is still isUnderRoot's, which is why the PowerShell
37
37
  * StartsWith is only a cheap pre-filter.
38
38
  */
39
39
  function toHostMembershipPath(windowsPath: string): string {
40
40
  const lowered = windowsPath.toLowerCase();
41
- return sep === "\\" ? lowered : lowered.replaceAll("\\", "/");
41
+ return lowered.replace(/[\\/]/g, sep);
42
42
  }
43
43
 
44
44
  function isMemberExecutable(executable: string, root: string): boolean {
@@ -91,10 +91,10 @@ function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): De
91
91
  const literal = install.root.replace(/'/g, "''");
92
92
  const script = [
93
93
  "$ErrorActionPreference='SilentlyContinue'",
94
- `$root = '${literal}'`,
94
+ `$root = '${literal}'.Replace('/', '\\')`,
95
95
  "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name",
96
96
  "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |",
97
- " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |",
97
+ " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.Replace('/', '\\').StartsWith($root, 'OrdinalIgnoreCase') } |",
98
98
  " ForEach-Object {",
99
99
  " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner",
100
100
  " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {",