@bitkyc08/opencodex 2.60.0 → 2.61.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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -4,6 +4,7 @@ import { contextCompatibleBaseLine } from "../context-compat";
4
4
  import { resolveEffectiveProjectModelProvider } from "../project-config-warnings";
5
5
  import {
6
6
  OCX_SECTION_MARKER,
7
+ OCX_ROUTING_MARKER_LINE,
7
8
  REALTIME_WS_BASE_URL_KEY,
8
9
  isRootOpenaiBaseUrlLine,
9
10
  isRootRealtimeWsBaseUrlLine,
@@ -128,7 +129,7 @@ export function buildProviderTableBlockForTarget(
128
129
  ): string {
129
130
  const lines = [
130
131
  "",
131
- OCX_SECTION_MARKER,
132
+ OCX_ROUTING_MARKER_LINE,
132
133
  "[model_providers.opencodex]",
133
134
  `name = ${tomlString(resolveCodexProviderDisplayName(displayName))}`,
134
135
  `base_url = ${tomlString(target.baseUrl)}`,
@@ -213,6 +214,9 @@ export function setRootOpenaiBaseUrl(
213
214
  if (!isRootOpenaiBaseUrlLine(lines[i])) continue;
214
215
  const markerOwned = i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER);
215
216
  if (!markerOwned) return { content, keptUserBaseUrl: true };
217
+ // Refresh the marker too, so a config injected by a build that predates the recovery
218
+ // hint gains it on the next `ocx start` instead of keeping a bare marker forever.
219
+ lines[i - 1] = OCX_ROUTING_MARKER_LINE;
216
220
  lines[i] = key;
217
221
  return { content: lines.join("\n"), keptUserBaseUrl: false };
218
222
  }
@@ -222,7 +226,7 @@ export function setRootOpenaiBaseUrl(
222
226
  content:
223
227
  content.replace(/\n+$/, "") +
224
228
  "\n" +
225
- OCX_SECTION_MARKER +
229
+ OCX_ROUTING_MARKER_LINE +
226
230
  "\n" +
227
231
  key +
228
232
  "\n",
@@ -231,7 +235,7 @@ export function setRootOpenaiBaseUrl(
231
235
  }
232
236
  let insertAt = firstTable;
233
237
  while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
234
- lines.splice(insertAt, 0, OCX_SECTION_MARKER, key);
238
+ lines.splice(insertAt, 0, OCX_ROUTING_MARKER_LINE, key);
235
239
  return { content: lines.join("\n"), keptUserBaseUrl: false };
236
240
  }
237
241
 
@@ -247,18 +251,19 @@ export function setRootOpenaiBaseUrlForTarget(
247
251
  if (!isRootOpenaiBaseUrlLine(lines[index])) continue;
248
252
  const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER);
249
253
  if (!markerOwned) return { content, keptUserBaseUrl: true };
254
+ lines[index - 1] = OCX_ROUTING_MARKER_LINE;
250
255
  lines[index] = key;
251
256
  return { content: lines.join("\n"), keptUserBaseUrl: false };
252
257
  }
253
258
  if (firstTable === -1) {
254
259
  return {
255
- content: `${content.replace(/\n+$/, "")}\n${OCX_SECTION_MARKER}\n${key}\n`,
260
+ content: `${content.replace(/\n+$/, "")}\n${OCX_ROUTING_MARKER_LINE}\n${key}\n`,
256
261
  keptUserBaseUrl: false,
257
262
  };
258
263
  }
259
264
  let insertAt = firstTable;
260
265
  while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt -= 1;
261
- lines.splice(insertAt, 0, OCX_SECTION_MARKER, key);
266
+ lines.splice(insertAt, 0, OCX_ROUTING_MARKER_LINE, key);
262
267
  return { content: lines.join("\n"), keptUserBaseUrl: false };
263
268
  }
264
269
 
@@ -284,13 +289,14 @@ export function setRootRealtimeWsBaseUrl(
284
289
  if (!isRootRealtimeWsBaseUrlLine(lines[index])) continue;
285
290
  const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER);
286
291
  if (!markerOwned) return { content, keptUserRealtimeWsBaseUrl: true };
292
+ lines[index - 1] = OCX_ROUTING_MARKER_LINE;
287
293
  lines[index] = key;
288
294
  return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false };
289
295
  }
290
296
  for (let index = 0; index < rootEnd; index += 1) {
291
297
  if (!isRootOpenaiBaseUrlLine(lines[index])) continue;
292
298
  if (!(index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER))) continue;
293
- lines.splice(index + 1, 0, OCX_SECTION_MARKER, key);
299
+ lines.splice(index + 1, 0, OCX_ROUTING_MARKER_LINE, key);
294
300
  return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false };
295
301
  }
296
302
  // No marker-owned routing override to attach to: the override has no owner, so inject nothing.
@@ -603,3 +609,32 @@ export function chooseCatalogPathForInjection(
603
609
 
604
610
  return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null;
605
611
  }
612
+
613
+ /**
614
+ * The effective `model_catalog_json` is one of ours and the file is gone.
615
+ *
616
+ * Codex does not degrade on this: a catalog path it cannot read stops it loading its
617
+ * configuration at all, which looks exactly like the routing lockout in #5261 and is what the
618
+ * reporter's machine was left in after the catalog file was deleted by hand.
619
+ *
620
+ * Injection already repairs it — the chooser refuses a missing owned path and the caller strips
621
+ * the stale line. That only helps someone who runs opencodex again, and the whole difficulty of
622
+ * this state is that Codex is the thing that stopped working, so nothing prompts them to. This
623
+ * predicate exists so the CLI can say it out loud.
624
+ *
625
+ * A user-owned catalog assignment wins here exactly as it does during injection: if they named
626
+ * the file, its absence is theirs to explain, and we do not claim it.
627
+ *
628
+ * Ownership is decided by basename, which is a weak test — a file the user happens to name
629
+ * `opencodex-catalog.json` is read as ours wherever it sits. That is deliberate rather than
630
+ * overlooked: it is the same test injection already applies, and a detector that drew the line
631
+ * somewhere else would report a state injection would then treat differently. Tightening it is a
632
+ * change to injection, not to this.
633
+ */
634
+ export function missingOwnedCatalogPath(content: string): string | null {
635
+ const existing = readRootModelCatalogPath(content);
636
+ if (!existing) return null;
637
+ const resolved = resolveCodexConfigPath(existing);
638
+ if (!isOpencodexCatalogPath(resolved)) return null;
639
+ return existsSync(resolved) ? null : existing;
640
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The way forward for a provider-table transition that finds an `openai`-tagged row
3
+ * Codex has already migrated to paginated history.
4
+ *
5
+ * The refusal this resolves is correct about the danger and wrong about the remedy.
6
+ * A provider-table transition takes the root `openai_base_url` out, and a paginated row
7
+ * cannot be relabeled, so the transition as planned would send that conversation to
8
+ * Codex's built-in OpenAI endpoint. Refusing to relabel is right. Refusing the whole
9
+ * transition left #5321's reporter with 173 conversations and two unsupported exits:
10
+ * delete them, or downgrade.
11
+ *
12
+ * There is a third state, and the injector already builds it for the client-compaction
13
+ * form: keep the marker-owned root override beside the provider table. Codex merges the
14
+ * override onto its built-in `openai` entry when it builds the provider map, so the
15
+ * paginated row keeps reaching this proxy while never being rewritten. The transition
16
+ * completes, the relabel stands down, and no rollout byte or thread row is touched.
17
+ *
18
+ * Two cases cannot reach that state. An admission-token form cannot use the root key at
19
+ * all, because Codex's built-in `openai` entry carries no `x-opencodex-api-key` header;
20
+ * that one keeps the refusal and names what the operator can actually do. A root line the
21
+ * user owns is left alone, and the conversation follows their configuration rather than
22
+ * this proxy — the same guarantee the injector already makes everywhere else about a line
23
+ * it does not own.
24
+ */
25
+ import { HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, HISTORY_RELABEL_STANDS_DOWN } from "../history-provider";
26
+ import { applyEol, setRootOpenaiBaseUrlForTarget, stripInjectedOpenaiBaseUrl } from "./config-toml";
27
+ import type { CodexRoutingTarget } from "./routing-target";
28
+
29
+ export interface PaginatedOpenaiCompatDecision {
30
+ /** The refusal that survives. `null` only when the preflight raised none. */
31
+ readonly refusal: string | null;
32
+ /** Candidate config bytes, carrying the retained root override when one was written. */
33
+ readonly content: string;
34
+ /** True only when OpenCodex wrote the retained line and must journal it as its own. */
35
+ readonly retainedRootOverride: boolean;
36
+ /** What the caller reports when `refusal` is not the stand-down reason. */
37
+ readonly message: string;
38
+ }
39
+
40
+ function genericRefusal(refusal: string): string {
41
+ return `Codex config injection refused: ${refusal}. `
42
+ + "Existing provider definitions and conversation files were preserved. "
43
+ + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly.";
44
+ }
45
+
46
+ /**
47
+ * Named next actions, because "do not retry" is what trapped the reporter. Both are
48
+ * configuration the operator already owns: the loopback listener is what makes the root
49
+ * override usable, and `syncResumeHistory` is the existing opt-out from history remapping.
50
+ */
51
+ function admissionTokenRefusal(): string {
52
+ return `Codex config injection refused: ${HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE}. `
53
+ + "Existing provider definitions and conversation files were preserved. "
54
+ + "This home has conversations tagged openai whose history Codex has already migrated to its paginated "
55
+ + "format, and they cannot be relabeled. Keeping them on this proxy needs a root openai_base_url override, "
56
+ + "which this routing form cannot use: Codex's built-in openai provider carries no x-opencodex-api-key header. "
57
+ + "To complete the transition, either route Codex through the loopback listener "
58
+ + "(unauthenticatedLoopbackListener.enabled, or a loopback hostname) so the override can be retained, "
59
+ + "or set syncResumeHistory to false to accept that those conversations resume against Codex's own OpenAI endpoint.";
60
+ }
61
+
62
+ /**
63
+ * Resolve the transition, retaining the root override when this routing form can own one.
64
+ *
65
+ * `content` is the fully assembled provider-table candidate: OpenCodex's own root override
66
+ * has already been stripped and not re-added, so a root `openai_base_url` still present in it
67
+ * belongs to the user.
68
+ */
69
+ export function applyPaginatedOpenaiCompat(
70
+ refusal: string | null,
71
+ target: CodexRoutingTarget,
72
+ content: string,
73
+ eol: "\r\n" | "\n",
74
+ ): PaginatedOpenaiCompatDecision {
75
+ if (refusal !== HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE) {
76
+ return { refusal, content, retainedRootOverride: false, message: refusal ? genericRefusal(refusal) : "" };
77
+ }
78
+ if (target.requiresAdmissionToken === true) {
79
+ return { refusal, content, retainedRootOverride: false, message: admissionTokenRefusal() };
80
+ }
81
+ const written = setRootOpenaiBaseUrlForTarget(stripInjectedOpenaiBaseUrl(applyEol(content, "\n")), target);
82
+ // The relabel still stands down either way: the row stays tagged openai and paginated, and
83
+ // the caller uses this reason to skip the history unit rather than let it start and refuse.
84
+ return {
85
+ refusal: HISTORY_RELABEL_STANDS_DOWN,
86
+ content: written.keptUserBaseUrl ? content : applyEol(written.content, eol),
87
+ retainedRootOverride: !written.keptUserBaseUrl,
88
+ message: "",
89
+ };
90
+ }
@@ -41,6 +41,7 @@ import {
41
41
  writeJournal,
42
42
  } from "./journal";
43
43
  import { HISTORY_RELABEL_STANDS_DOWN, preflightCodexHistoryInjection } from "./history-provider";
44
+ import { applyPaginatedOpenaiCompat } from "./inject/paginated-openai-compat";
44
45
  import {
45
46
  describeHistoryJobFailure,
46
47
  deriveCodexHistoryOperation,
@@ -347,12 +348,13 @@ async function injectCodexConfigImpl(
347
348
  // first-line repair cannot grow "openai" into "opencodex" without pre-existing padding, and
348
349
  // codex re-appends that stale first line whenever it writes git or memory-mode metadata.
349
350
  //
350
- // Authless is excluded on purpose: its whole point is a provider that carries
351
- // requires_openai_auth = false, and admission-token forms cannot use the root key at all.
352
- // Those two forms therefore keep their existing behaviour, forward-tagging resume history with
353
- // originals backed up, and that includes the case where a user enables authless and client
354
- // compaction together. Only the compaction-only form skips the history unit.
355
- const keepRootOverrideAlongsideTable = providerTableMode
351
+ // Authless is excluded here on purpose: its whole point is a provider that carries
352
+ // requires_openai_auth = false, so it forward-tags resume history with originals backed up
353
+ // instead, and that includes the case where a user enables authless and client compaction
354
+ // together. Only the compaction-only form skips the history unit up front. When forward
355
+ // tagging turns out to be impossible because Codex already paginated those rows, the same
356
+ // retention is selected below from the preflight verdict rather than from the routing form.
357
+ let keepRootOverrideAlongsideTable = providerTableMode
356
358
  && routingTarget.clientCompaction === true
357
359
  && routingTarget.desktopAuthless !== true
358
360
  && routingTarget.requiresAdmissionToken !== true;
@@ -472,14 +474,15 @@ async function injectCodexConfigImpl(
472
474
  if (observed && observed !== HISTORY_RELABEL_STANDS_DOWN) throw new CodexHistoryPreflightRefusal(observed);
473
475
  return observed;
474
476
  };
475
- const observedHistoryRefusal = historyPreflight();
477
+ const compat = applyPaginatedOpenaiCompat(historyPreflight(), routingTarget, content, eol);
478
+ content = compat.content;
479
+ keepRootOverrideAlongsideTable ||= compat.retainedRootOverride;
480
+ const observedHistoryRefusal = compat.refusal;
476
481
  if (observedHistoryRefusal && observedHistoryRefusal !== HISTORY_RELABEL_STANDS_DOWN) {
477
482
  return {
478
483
  success: false,
479
484
  historyPreflightFailureReason: observedHistoryRefusal,
480
- message: `Codex config injection refused: ${observedHistoryRefusal}. `
481
- + "Existing provider definitions and conversation files were preserved. "
482
- + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly.",
485
+ message: compat.message,
483
486
  };
484
487
  }
485
488
  let historyRelabelRefusal = observedHistoryRefusal;
@@ -607,11 +610,11 @@ async function injectCodexConfigImpl(
607
610
  atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
608
611
  markJournalInjectedState(content, profileContent, {
609
612
  // A root override is ours whenever we wrote one and no user-owned value won. That is
610
- // loopback Design B, and now also the client-compaction form, which keeps the same
611
- // marker-owned root line beside its provider table. Journaling it matters because the
612
- // marker comment is not durable: the Codex app can reserialize config.toml and drop
613
- // comments, and restore then has only the journaled value to tell our line from a user's
614
- // (#1798). The other table forms never write the key, so they still record null.
613
+ // loopback Design B, the client-compaction form, and any table form that retained the
614
+ // root line for a paginated openai row, all of which keep the marker-owned line beside
615
+ // the table. Journaling it matters because the marker comment is not durable: the Codex
616
+ // app can reserialize config.toml and drop comments, and restore then has only the
617
+ // journaled value to tell our line from a user's (#1798). Other table forms record null.
615
618
  injectedOpenaiBaseUrl: (providerTableMode && !keepRootOverrideAlongsideTable) || keptUserBaseUrl
616
619
  ? null
617
620
  : rootTomlString(content, "openai_base_url"),
@@ -11,6 +11,24 @@ import { parseTomlString } from "./paths";
11
11
 
12
12
  export const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
13
13
 
14
+ /**
15
+ * The marker line actually written above ROUTING keys, carrying the command that undoes them.
16
+ *
17
+ * #5261: a Windows user whose proxy had stopped was locked out of Codex sign-in, because the
18
+ * root `openai_base_url` we write keeps pointing Codex's built-in openai provider at a port
19
+ * nothing is listening on. The only surface such a user can still read is `config.toml` itself,
20
+ * and it said nothing but "Auto-injected by opencodex" — so the recovery they found was to
21
+ * hand-delete lines and the catalog file, which is strictly worse than `ocx restore`.
22
+ *
23
+ * Every ownership predicate below matches on {@link OCX_SECTION_MARKER} as a SUBSTRING, never by
24
+ * equality, so this longer line is recognized by them and by any opencodex old enough to predate
25
+ * it. That is the whole reason the hint is appended to the marker instead of occupying a line of
26
+ * its own: a separate comment line would survive removal as orphaned text.
27
+ *
28
+ * Scope is routing only. Prompt layers keep the bare marker: `ocx restore` is not their undo.
29
+ */
30
+ export const OCX_ROUTING_MARKER_LINE = `${OCX_SECTION_MARKER} (undo: ocx restore)`;
31
+
14
32
  export function isRootOpenaiBaseUrlLine(line: string): boolean {
15
33
  return /^\s*openai_base_url\s*=/.test(line);
16
34
  }
@@ -404,6 +404,12 @@ async function resolveMainAccountToken(
404
404
  : "transient" as const;
405
405
  throw new MainAccountTokenRefreshError(reason, { cause });
406
406
  }
407
+ // The refresh may resolve after the caller went away (an implementation that does
408
+ // not observe the signal, or an abort landing in the window between resolution and
409
+ // commit). A cancelled request's late refresh must not rewrite auth.json on behalf
410
+ // of a request that no longer exists -- the same fence the reauth twin applies
411
+ // before its own commit above.
412
+ if (dependencies.signal?.aborted) throw dependencies.signal.reason;
407
413
  const result = persistRefreshedMainAuthJson(locked, refreshed);
408
414
  if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
409
415
  return result;
@@ -10,6 +10,7 @@
10
10
  import type { CatalogModel } from "./catalog";
11
11
  import type { GenerationContext } from "../lib/state-store-sweeper";
12
12
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
13
+ import { clearLiveCursorRosterState } from "../adapters/cursor/catalog";
13
14
 
14
15
  /** Default freshness window. Matches Codex's own 5-min models cache so the two stay in step. */
15
16
  export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -80,7 +81,17 @@ function deleteCachedProvider(provider: string): number {
80
81
  * the full fetch timeout on every catalog poll (issue #54: UI stalls behind corporate proxies). */
81
82
  export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000;
82
83
 
83
- const failureAt = new Map<string, number>();
84
+ interface DiscoveryFailure {
85
+ at: number;
86
+ /**
87
+ * Credential the failure was observed under, for entitlement-specific rosters. Absent means
88
+ * the failure is credential-agnostic (a plain `/models` endpoint) and suppresses every
89
+ * caller, which is the original #54 behaviour.
90
+ */
91
+ authorityIdentity?: string;
92
+ }
93
+
94
+ const failureAt = new Map<string, DiscoveryFailure>();
84
95
  const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
85
96
  /**
86
97
  * How many models the last successful discovery actually returned, before any configured-alias
@@ -91,8 +102,12 @@ const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
91
102
  const liveModelCounts = new Map<string, number>();
92
103
  let lastReconciledGeneration = 0;
93
104
 
94
- export function markModelsFetchFailure(provider: string, now = Date.now()): void {
95
- failureAt.set(provider, now);
105
+ export function markModelsFetchFailure(
106
+ provider: string,
107
+ now = Date.now(),
108
+ authorityIdentity?: string,
109
+ ): void {
110
+ failureAt.set(provider, { at: now, ...(authorityIdentity ? { authorityIdentity } : {}) });
96
111
  }
97
112
 
98
113
  /** `liveModelCount` is required so a caller that forgets to pass it fails typecheck instead of
@@ -147,9 +162,25 @@ export function getProviderLiveModelCount(provider: string): number | undefined
147
162
  return liveModelCounts.get(provider);
148
163
  }
149
164
 
150
- export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean {
151
- const at = failureAt.get(provider);
152
- return at !== undefined && now - at < cooldownMs;
165
+ /**
166
+ * Whether a failed discovery still suppresses the next one.
167
+ *
168
+ * `authorityIdentity` scopes the suppression to the credential that actually observed the
169
+ * failure. A roster that upstream filters per account is evidence about that account, and
170
+ * one account's 401 or 404 must not decide that a different account has no catalog. A
171
+ * failure recorded without an identity stays credential-agnostic and suppresses everyone,
172
+ * so the plain-endpoint providers keep the timeout protection #54 added.
173
+ */
174
+ export function isModelsFetchCoolingDown(
175
+ provider: string,
176
+ cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS,
177
+ now = Date.now(),
178
+ authorityIdentity?: string,
179
+ ): boolean {
180
+ const failure = failureAt.get(provider);
181
+ if (failure === undefined || now - failure.at >= cooldownMs) return false;
182
+ if (failure.authorityIdentity === undefined || authorityIdentity === undefined) return true;
183
+ return failure.authorityIdentity === authorityIdentity;
153
184
  }
154
185
 
155
186
  /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */
@@ -168,6 +199,20 @@ export function getStaleCached(provider: string, authorityIdentity?: string): Ca
168
199
  return entry.models;
169
200
  }
170
201
 
202
+ /** Selector decoding may use unscoped rows, but scoped rows need current authority. */
203
+ export function getRoutingCached(
204
+ provider: string, resolveAuthority: () => string | undefined,
205
+ ): CatalogModel[] | null {
206
+ const entry = cache.get(provider);
207
+ if (!entry) return null;
208
+ if (entry.authorityIdentity !== undefined) {
209
+ try {
210
+ if (resolveAuthority() !== entry.authorityIdentity) return null;
211
+ } catch { return null; }
212
+ }
213
+ return entry.models;
214
+ }
215
+
171
216
  /** Capture the cache generation before an asynchronous provider discovery starts. */
172
217
  export function captureModelCacheGeneration(provider: string): string {
173
218
  if (!providerCacheGenerations.has(provider)) providerCacheGenerations.set(provider, 0);
@@ -240,6 +285,7 @@ export function clearModelCache(
240
285
  provider?: string,
241
286
  reason: ModelCacheClearReason = "authority",
242
287
  ): void {
288
+ clearLiveCursorRosterState(provider);
243
289
  const revokesInFlightDiscovery = reason === "authority";
244
290
  if (provider) {
245
291
  if (revokesInFlightDiscovery) {
@@ -0,0 +1,59 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
3
+ import {
4
+ resolveCodexModelEntitlements,
5
+ type CodexModelEntitlementResolveOptions,
6
+ type CodexModelEntitlementSnapshot,
7
+ } from "./model-entitlements";
8
+ import {
9
+ type NativeMainCredentialAdmissionDeps,
10
+ withNativeMainCredentialAdmission,
11
+ } from "./native-main-admission";
12
+
13
+ interface ModelEntitlementAdmissionDeps extends NativeMainCredentialAdmissionDeps {
14
+ readonly resolve?: typeof resolveCodexModelEntitlements;
15
+ }
16
+
17
+ function excludeNativeMain(
18
+ options: CodexModelEntitlementResolveOptions,
19
+ ): CodexModelEntitlementResolveOptions {
20
+ return {
21
+ ...options,
22
+ excludeAccountIds: new Set([
23
+ ...(options.excludeAccountIds ?? []),
24
+ MAIN_CODEX_ACCOUNT_ID,
25
+ ]),
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Resolve background/data-plane entitlements inside the native-main fences.
31
+ *
32
+ * Pool discovery remains available when startup recovery or a profile drain
33
+ * owns the physical credential. When main is admitted, the process-local lease
34
+ * covers only the credential snapshot: getValidMainAccountToken holds the
35
+ * cross-process exclusive claim just for the auth.json refresh write, and the
36
+ * lease is released before the upstream entitlement request so a profile drain
37
+ * never waits on a network fetch.
38
+ */
39
+ export async function resolveAdmittedCodexModelEntitlements(
40
+ config: Pick<OcxConfig, "codexAccounts">,
41
+ options: CodexModelEntitlementResolveOptions = {},
42
+ deps: ModelEntitlementAdmissionDeps = {},
43
+ ): Promise<CodexModelEntitlementSnapshot> {
44
+ const resolve = deps.resolve ?? resolveCodexModelEntitlements;
45
+ // A caller-supplied roster or an already-excluded main never reads auth.json,
46
+ // so there is no native credential to fence.
47
+ if (options.credentials || options.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)) {
48
+ return resolve(config, options);
49
+ }
50
+ return withNativeMainCredentialAdmission(
51
+ (excludedAccountIds, releaseMainLease) => resolve(
52
+ config,
53
+ excludedAccountIds.size === 0
54
+ ? { ...options, releaseNativeMainCredentialLease: releaseMainLease }
55
+ : excludeNativeMain(options),
56
+ ),
57
+ { acquireNativeMain: deps.acquireNativeMain },
58
+ );
59
+ }
@@ -13,6 +13,7 @@ import {
13
13
  MAIN_CODEX_ACCOUNT_ID,
14
14
  type NativeMainRefreshDependencies,
15
15
  } from "./main-account";
16
+ import { withNativeMainCredentialAdmission } from "./native-main-admission";
16
17
  import {
17
18
  ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
18
19
  NATIVE_GPT6_ASTRA_MODEL,
@@ -439,6 +440,12 @@ export interface CodexModelEntitlementResolveOptions {
439
440
  readonly excludeAccountIds?: ReadonlySet<string>;
440
441
  /** Ensure-only fence; ordinary request resolvers retain their established flight identity. */
441
442
  readonly credentialMutationEpoch?: number;
443
+ /**
444
+ * Internal plumbing from `withNativeMainCredentialAdmission`: releases the
445
+ * native-main lifecycle lease once the credential phase settles, before any
446
+ * upstream roster fetch, so a profile drain never waits on network work.
447
+ */
448
+ readonly releaseNativeMainCredentialLease?: () => void;
442
449
  }
443
450
 
444
451
  export interface CodexEntitlementFreshnessOptions extends Pick<
@@ -452,6 +459,8 @@ export interface CodexEntitlementFreshnessOptions extends Pick<
452
459
  | "signal"
453
460
  > {
454
461
  readonly waitMs?: number;
462
+ /** Test seam for the native-main admission fence around the refresh workset. */
463
+ readonly nativeMainCredentialAdmission?: typeof withNativeMainCredentialAdmission;
455
464
  }
456
465
 
457
466
  const accountModelsCache = new Map<string, CachedAccountModels>();
@@ -581,21 +590,31 @@ function currentCredentialIdentity(accountId: string): string | undefined {
581
590
 
582
591
  async function accountCredentialSnapshot(
583
592
  accountId: string,
584
- options: Pick<CodexModelEntitlementResolveOptions, "nativeMainRefreshDependencies" | "signal"> = {},
593
+ options: Pick<
594
+ CodexModelEntitlementResolveOptions,
595
+ "nativeMainRefreshDependencies" | "releaseNativeMainCredentialLease" | "signal"
596
+ > = {},
585
597
  ): Promise<CodexModelEntitlementCredentialSnapshot | null> {
586
598
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
587
- const token = await getValidMainAccountToken({
588
- signal: options.signal,
589
- ...(options.nativeMainRefreshDependencies ?? {}),
590
- });
591
- return token
592
- ? {
593
- accountId,
594
- accessToken: token.accessToken,
595
- chatgptAccountId: token.chatgptAccountId,
596
- credentialIdentity: `main:${token.chatgptAccountId}`,
597
- }
598
- : null;
599
+ try {
600
+ const token = await getValidMainAccountToken({
601
+ signal: options.signal,
602
+ ...(options.nativeMainRefreshDependencies ?? {}),
603
+ });
604
+ return token
605
+ ? {
606
+ accountId,
607
+ accessToken: token.accessToken,
608
+ chatgptAccountId: token.chatgptAccountId,
609
+ credentialIdentity: `main:${token.chatgptAccountId}`,
610
+ }
611
+ : null;
612
+ } finally {
613
+ // The lifecycle lease fences only this credential read; releasing here —
614
+ // on success and on a credential-ownership failure alike — keeps a
615
+ // profile drain from waiting on the roster fetches that follow.
616
+ options.releaseNativeMainCredentialLease?.();
617
+ }
599
618
  }
600
619
  try {
601
620
  const token = await getValidCodexToken(accountId);
@@ -911,38 +930,58 @@ async function refreshCodexEntitlementWorkset(
911
930
  mutationEpoch: number,
912
931
  options: CodexEntitlementFreshnessOptions,
913
932
  ): Promise<void> {
914
- const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
915
- const observations = await Promise.all(workset.map(async accountId => {
916
- const credential = await credentialSnapshot(accountId, options);
917
- return {
918
- accountId,
919
- credential,
920
- absenceObservedAt: options.now ?? Date.now(),
921
- };
922
- }));
923
- const credentials = observations.flatMap(observation => observation.credential
924
- ? [observation.credential]
925
- : []);
926
- if (credentials.length > 0) {
927
- await resolveCodexModelEntitlements(config, {
928
- ...options,
929
- clientVersion,
930
- credentialMutationEpoch: mutationEpoch,
931
- credentials,
932
- });
933
- }
933
+ const run = async (
934
+ excludedAccountIds: ReadonlySet<string>,
935
+ releaseMainLease?: () => void,
936
+ ): Promise<void> => {
937
+ // An excluded main is filtered before the snapshot phase, not just before the
938
+ // roster fetch: it never produces an absence observation, so a denied
939
+ // admission cannot memoize a credential read that never happened.
940
+ const admittedWorkset = excludedAccountIds.size === 0
941
+ ? workset
942
+ : workset.filter(accountId => !excludedAccountIds.has(accountId));
943
+ const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
944
+ const observations = await Promise.all(admittedWorkset.map(async accountId => {
945
+ const credential = await credentialSnapshot(accountId, {
946
+ ...options,
947
+ releaseNativeMainCredentialLease: releaseMainLease,
948
+ });
949
+ return {
950
+ accountId,
951
+ credential,
952
+ absenceObservedAt: options.now ?? Date.now(),
953
+ };
954
+ }));
955
+ // The lease fences only the credential phase; release before roster fetches
956
+ // so a profile drain never waits on upstream network work.
957
+ releaseMainLease?.();
958
+ const credentials = observations.flatMap(observation => observation.credential
959
+ ? [observation.credential]
960
+ : []);
961
+ if (credentials.length > 0) {
962
+ await resolveCodexModelEntitlements(config, {
963
+ ...options,
964
+ clientVersion,
965
+ credentialMutationEpoch: mutationEpoch,
966
+ credentials,
967
+ });
968
+ }
934
969
 
935
- for (const observation of observations) {
936
- if (observation.credential) continue;
937
- const capturedIdentity = identityVector.get(observation.accountId) ?? null;
938
- if (codexCredentialMutationEpoch() !== mutationEpoch) continue;
939
- if ((currentCredentialIdentity(observation.accountId) ?? null) !== capturedIdentity) continue;
940
- boundedNegativeCredentialMemoSet(observation.accountId, {
941
- credentialIdentity: capturedIdentity,
942
- mutationEpoch,
943
- expiresAt: observation.absenceObservedAt + MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS,
944
- });
945
- }
970
+ for (const observation of observations) {
971
+ if (observation.credential) continue;
972
+ const capturedIdentity = identityVector.get(observation.accountId) ?? null;
973
+ if (codexCredentialMutationEpoch() !== mutationEpoch) continue;
974
+ if ((currentCredentialIdentity(observation.accountId) ?? null) !== capturedIdentity) continue;
975
+ boundedNegativeCredentialMemoSet(observation.accountId, {
976
+ credentialIdentity: capturedIdentity,
977
+ mutationEpoch,
978
+ expiresAt: observation.absenceObservedAt + MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS,
979
+ });
980
+ }
981
+ };
982
+ if (!workset.includes(MAIN_CODEX_ACCOUNT_ID)) return run(new Set<string>());
983
+ const admission = options.nativeMainCredentialAdmission ?? withNativeMainCredentialAdmission;
984
+ return admission(run);
946
985
  }
947
986
 
948
987
  function waitForEntitlementEnsureFlight(
@@ -1110,6 +1149,10 @@ export async function resolveCodexModelEntitlements(
1110
1149
  ? [...options.credentials].filter(credential => !options.excludeAccountIds?.has(credential.accountId))
1111
1150
  : (await Promise.all(allowedAccountIds.map(accountId => credentialSnapshot(accountId, options))))
1112
1151
  .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
1152
+ // The credential phase is the only part the native-main lease fences. The
1153
+ // real snapshot releases it as soon as the main token settles; this boundary
1154
+ // release keeps the guarantee when a seam snapshot never invokes it.
1155
+ options.releaseNativeMainCredentialLease?.();
1113
1156
  const results = await Promise.all(credentials.map(async credential => ({
1114
1157
  credential,
1115
1158
  result: await modelsForCredential(