@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) 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-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.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 +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -29,33 +29,14 @@ export { CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_M
29
29
  export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
30
30
 
31
31
  /**
32
- * Dial URL for a request URL. The canonical ChatGPT backend keeps its constant;
33
- * an operator-opted OpenAI-compatible upstream swaps https for wss on the same
34
- * path so gateways that serve the Responses WebSocket protocol on their
35
- * /v1/responses path get the same fast lane. Plain HTTP remains on SSE because
36
- * a provider WS handshake would otherwise send credentials and request data
37
- * without transport encryption.
32
+ * Dial URL for a first-party Responses endpoint. The canonical ChatGPT backend
33
+ * keeps its constant; the api.openai.com Responses endpoint swaps https for wss
34
+ * on the same path. No other upstream may enter the WebSocket lane.
38
35
  */
39
36
  function wsUpstreamUrlFor(httpUrl: string): string {
40
37
  if (httpUrl === CODEX_RESPONSES_HTTP_URL) return CODEX_RESPONSES_WS_URL;
41
- return httpUrl.replace(/^http(s?):/, "ws$1:");
42
- }
43
-
44
- /**
45
- * An operator-opted OpenAI-compatible upstream only joins the WS lane for
46
- * Responses endpoints: the WebSocket path speaks the Responses event protocol,
47
- * and every downstream consumer (adapter parsers, usage sniffing, SSE relay)
48
- * assumes that wire. Other paths (chat completions, images, search) stay HTTP.
49
- */
50
- function isResponsesWebsocketEligibleUrl(url: string): boolean {
51
- let parsed: URL;
52
- try {
53
- parsed = new URL(url);
54
- } catch {
55
- return false;
56
- }
57
- return parsed.protocol === "https:"
58
- && parsed.pathname.endsWith("/responses");
38
+ if (httpUrl === OPENAI_API_RESPONSES_URL) return httpUrl.replace(/^http(s?):/, "ws$1:");
39
+ throw new Error("unsupported Codex WebSocket upstream");
59
40
  }
60
41
  export type BunRuntimeIdentity = {
61
42
  version: string;
@@ -108,8 +89,13 @@ export function shouldUseCodexWsUpstream(
108
89
  ): boolean {
109
90
  if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
110
91
  if (socks5ProxyFromEnv()) return false;
111
- if (url !== CODEX_RESPONSES_HTTP_URL && !upstreamWebsocketConfigured) return false;
112
- if (upstreamWebsocketConfigured && !isResponsesWebsocketEligibleUrl(url)) return false;
92
+ // Bun's client WebSocket API delivers only fully assembled messages and has
93
+ // no enforceable inbound payload limit. Keep arbitrary provider endpoints on
94
+ // bounded HTTP/SSE until the client can reject fragmented text and binary
95
+ // messages during ingestion rather than after allocation. The first-party
96
+ // api.openai.com lane still requires the operator opt-in.
97
+ if (url !== CODEX_RESPONSES_HTTP_URL
98
+ && !(upstreamWebsocketConfigured && url === OPENAI_API_RESPONSES_URL)) return false;
113
99
  if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
114
100
  const body = init?.body;
115
101
  if (typeof body !== "string") return false;
@@ -139,7 +125,8 @@ export function codexWsUpstreamFetch(
139
125
  const prepared = prepareCodexWsRequest(url, init);
140
126
  if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init));
141
127
  init = prepared.httpInit;
142
- if (!bunSupportsBoundedCodexWsRelay(runtime)) {
128
+ if ((url !== CODEX_RESPONSES_HTTP_URL && url !== OPENAI_API_RESPONSES_URL)
129
+ || !bunSupportsBoundedCodexWsRelay(runtime)) {
143
130
  return sseFallback(url, init);
144
131
  }
145
132
  const signal = init.signal ?? undefined;
@@ -1,6 +1,7 @@
1
1
  import type { TranslatorBudget } from "../lib/translator-budget";
2
2
  import { mayBecomePatchEnvelope, normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope";
3
3
  import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
4
+ import { progressiveFreeformInput } from "../responses/progressive-freeform-input";
4
5
  import { declaresCodeModeExec } from "../types/tools";
5
6
  import {
6
7
  customToolItemId,
@@ -15,50 +16,10 @@ import {
15
16
  type SseBlockRewrite,
16
17
  } from "./sse-payload-rewrite";
17
18
 
18
- /** Exact compact prefix used by our upstream rewriter; progressive matching also
19
- * tolerates insignificant JSON whitespace via FREEFORM_WRAP_PREFIX_RE. */
20
- const FREEFORM_WRAP_PREFIX = '{"input":"';
21
- const FREEFORM_WRAP_PREFIX_RE = /^\s*\{\s*"input"\s*:\s*"/;
22
-
23
19
  function isPlainObject(value: unknown): value is Record<string, unknown> {
24
20
  return !!value && typeof value === "object" && !Array.isArray(value);
25
21
  }
26
22
 
27
- /**
28
- * Progressive decode of the freeform `{ "input": "…" }` wrapper.
29
- * Returns null when the accumulated text does not (yet) match that wrapper so
30
- * callers suppress deltas and rely on `response.custom_tool_call_input.done`.
31
- */
32
- function partialCustomToolInput(argumentsText: string): string | null {
33
- const match = FREEFORM_WRAP_PREFIX_RE.exec(argumentsText);
34
- if (!match) return null;
35
- const body = argumentsText.slice(match[0]!.length);
36
- let output = "";
37
- for (let index = 0; index < body.length; index++) {
38
- const char = body[index];
39
- if (char === '"') break;
40
- if (char !== "\\") {
41
- output += char;
42
- continue;
43
- }
44
- const escaped = body[index + 1];
45
- if (escaped === undefined) break;
46
- index += 1;
47
- if (escaped === "n") output += "\n";
48
- else if (escaped === "t") output += "\t";
49
- else if (escaped === "r") output += "\r";
50
- else if (escaped === "b") output += "\b";
51
- else if (escaped === "f") output += "\f";
52
- else if (escaped === "u") {
53
- const hex = body.slice(index + 1, index + 5);
54
- if (hex.length !== 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) break;
55
- output += String.fromCharCode(Number.parseInt(hex, 16));
56
- index += 4;
57
- } else output += escaped;
58
- }
59
- return output;
60
- }
61
-
62
23
  function replaceSseEventName(block: string, type: string): string {
63
24
  const newline = block.includes("\r\n") ? "\r\n" : "\n";
64
25
  const lines = block.split(/\r?\n/);
@@ -332,21 +293,33 @@ export function createRoutedCustomToolRestoreBlockRewrite(
332
293
  openCalls.set(upstreamItemId, open);
333
294
  // A helper alias will become JavaScript at completion, never raw patch/JSON.
334
295
  if (itemNames.get(upstreamItemId)?.aliased) return [];
335
- // Still accumulating toward the compact wrapper, or an unrecognized shape:
336
- // suppress progressive emission and let the done event carry input.
337
- if (FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText)) return [];
338
- const fullInput = partialCustomToolInput(open.argumentsText);
296
+ const itemName = itemNames.get(upstreamItemId);
297
+ const ownsFreeformGrammar = itemName?.namespace === undefined
298
+ || itemName?.namespace === "functions";
299
+ const fullInput = progressiveFreeformInput(
300
+ open.argumentsText,
301
+ ownsFreeformGrammar ? itemName?.name ?? "" : "",
302
+ );
339
303
  if (fullInput === null) return [];
340
- // Hold a buffer that could still become a complete patch envelope. The done event
341
- // recompiles such a body into an apply_patch helper call, so streaming the envelope
342
- // bytes first and replacing them at completion is the rewind this path forbids.
343
- // Mirrors the same hold in `src/bridge.ts`.
344
- if (
345
- declaresCodeModeExec(declaredNames)
346
- && itemNames.get(upstreamItemId)?.namespace === undefined
347
- && itemNames.get(upstreamItemId)?.name === "exec"
348
- && mayBecomePatchEnvelope(fullInput)
349
- ) return [];
304
+ // This routed path historically holds every unrecognized JSON object until done.
305
+ // The shared decoder streams ordinary raw input, so retain the stricter routed rule
306
+ // when no recognized wrapper transformed the accumulated object.
307
+ if (fullInput === open.argumentsText && open.argumentsText.trimStart().startsWith("{")) return [];
308
+ // Hold a buffer that could still become a complete patch envelope, for either of the two
309
+ // reasons completion rewrites one. Both are the same rewind this path forbids, and both
310
+ // mirror `src/bridge/sse.ts`.
311
+ //
312
+ // `exec`: the done event recompiles such a body into an apply_patch helper call.
313
+ // `apply_patch`: `normalizeApplyPatchDelimiters` rewrites a decorated
314
+ // `*** Begin Patch ***` envelope at completion, so the decorated markers would be
315
+ // published and then replaced by the normalized ones. Before the shared decoder, this
316
+ // path held every non-canonical shape and so never reached that case; now that ordinary
317
+ // raw input streams, the second reason has to be stated explicitly.
318
+ const mayCompile = declaresCodeModeExec(declaredNames)
319
+ && itemName?.namespace === undefined
320
+ && itemName?.name === "exec";
321
+ const mayNormalize = ownsFreeformGrammar && itemName?.name === "apply_patch";
322
+ if ((mayCompile || mayNormalize) && mayBecomePatchEnvelope(fullInput)) return [];
350
323
  if (!fullInput.startsWith(open.emittedInput) || fullInput.length === open.emittedInput.length) return [];
351
324
  const inputDelta = fullInput.slice(open.emittedInput.length);
352
325
  open.emittedInput = fullInput;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The tool selection a Responses request actually authorized, read from the final outbound body.
3
+ *
4
+ * The undeclared-tool guard answers whether a NAME was declared. This answers a different
5
+ * question: whether this request still permits a client tool call at all, and which names it
6
+ * permits. `tool_choice: "none"`, a forced selector and an `allowed_tools` allow-list each narrow
7
+ * the catalog without removing a declaration, so a name can be declared and forbidden at the same
8
+ * time — and a repair that rebuilds a terminal from collected items would otherwise hand the
9
+ * client a call the caller ruled out.
10
+ *
11
+ * The scope is read from the OUTBOUND body, after every removal, rename and translation, because
12
+ * that is the request the destination answered. A catalog that ends up empty there authorizes no
13
+ * client call whatever the selector still says.
14
+ */
15
+ import type { MuseToolNameAliases } from "../responses/muse-tool-name-alias";
16
+ import { museWireNameForOriginal } from "../responses/muse-tool-name-alias";
17
+ import type {
18
+ RoutedNamespaceToolAliases,
19
+ RoutedNamespaceToolIdentity,
20
+ } from "../responses/namespace-tool-compat";
21
+ import {
22
+ CLIENT_EXECUTED_CALL_TYPES,
23
+ collectDeclaredWireToolNames,
24
+ hasExplicitWireToolCatalog,
25
+ } from "./responses-undeclared-tool-guard";
26
+ import { isPlainObject } from "./responses-snapshot-codec";
27
+
28
+ type ToolKind = "function" | "custom";
29
+ const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
30
+
31
+ type ToolIdentity = Readonly<{
32
+ kind: ToolKind;
33
+ name: string;
34
+ namespace?: string;
35
+ }>;
36
+
37
+ export type RequestToolScopeCorrespondence = Readonly<{
38
+ clientToolAuthorizationBody?: unknown;
39
+ routedNamespaceToolAliases?: RoutedNamespaceToolAliases;
40
+ routedMuseToolNameAliases?: MuseToolNameAliases;
41
+ convertedRoutedCustomToolNames?: ReadonlySet<string>;
42
+ }>;
43
+
44
+ function identityKey(identity: ToolIdentity): string {
45
+ return JSON.stringify([identity.kind, identity.namespace ?? null, identity.name]);
46
+ }
47
+
48
+ function clientNamespace(value: unknown): string | undefined {
49
+ return typeof value === "string" && value.length > 0 && value !== BUILTIN_FUNCTIONS_NAMESPACE
50
+ ? value
51
+ : undefined;
52
+ }
53
+
54
+ function selectorIdentity(selector: unknown): ToolIdentity | undefined {
55
+ if (!isPlainObject(selector)) return undefined;
56
+ if (selector.type !== "function" && selector.type !== "custom") return undefined;
57
+ if (typeof selector.name !== "string" || selector.name.length === 0) return undefined;
58
+ if ("namespace" in selector && typeof selector.namespace !== "string") return undefined;
59
+ const namespace = clientNamespace(selector.namespace);
60
+ return { kind: selector.type, name: selector.name, ...(namespace ? { namespace } : {}) };
61
+ }
62
+
63
+ function callIdentity(item: Record<string, unknown>): ToolIdentity | undefined {
64
+ const kind = item.type === "function_call"
65
+ ? "function"
66
+ : item.type === "custom_tool_call"
67
+ ? "custom"
68
+ : undefined;
69
+ if (!kind || typeof item.name !== "string" || item.name.length === 0) return undefined;
70
+ const namespace = clientNamespace(item.namespace);
71
+ return { kind, name: item.name, ...(namespace ? { namespace } : {}) };
72
+ }
73
+
74
+ function sameRestoredIdentity(left: ToolIdentity, right: RoutedNamespaceToolIdentity): boolean {
75
+ return left.namespace === right.namespace
76
+ && left.name === right.name
77
+ && left.kind === right.kind;
78
+ }
79
+
80
+ /**
81
+ * Exact identities this restored call could have used on the final outbound wire.
82
+ *
83
+ * Namespace spellings come only from namespace-tool-compat's request-scoped, ambiguity-checked
84
+ * aliases. Muse aliases then compose over those wire names. A kind change is admitted only when
85
+ * the request recorded that exact custom identity as converted to a function.
86
+ */
87
+ function outboundCallIdentities(
88
+ item: Record<string, unknown>,
89
+ correspondence: RequestToolScopeCorrespondence,
90
+ ): ReadonlySet<string> {
91
+ const restored = callIdentity(item);
92
+ if (!restored) return new Set();
93
+ const keys = new Set<string>([identityKey(restored)]);
94
+ const museAliases = correspondence.routedMuseToolNameAliases ?? new Map();
95
+ const convertedCustom = correspondence.convertedRoutedCustomToolNames ?? new Set();
96
+ const originalSelection = isPlainObject(correspondence.clientToolAuthorizationBody)
97
+ ? toolSelection(correspondence.clientToolAuthorizationBody)
98
+ : UNRESTRICTED;
99
+ const originalSelectionAllowsRestored = originalSelection.kind === "allow"
100
+ && originalSelection.identities.has(identityKey(restored));
101
+
102
+ const addWireIdentity = (
103
+ preMuseName: string,
104
+ clientKind: ToolKind,
105
+ conversionIdentityVerified: boolean,
106
+ ): void => {
107
+ const name = museWireNameForOriginal(preMuseName, museAliases);
108
+ if (name === undefined) return;
109
+ const kind = clientKind === "custom"
110
+ && convertedCustom.has(preMuseName)
111
+ && conversionIdentityVerified
112
+ ? "function"
113
+ : clientKind;
114
+ keys.add(identityKey({ kind, name }));
115
+ };
116
+
117
+ if (restored.namespace === undefined) {
118
+ addWireIdentity(restored.name, restored.kind, originalSelectionAllowsRestored);
119
+ }
120
+ for (const [wireName, identity] of correspondence.routedNamespaceToolAliases ?? new Map()) {
121
+ // namespace-tool-compat emits only aliases authorized under the outbound selector's kind,
122
+ // then restores `custom` provenance from the request's conversion set. That exact alias edge is
123
+ // already the proof that this custom-to-function transition belongs to this identity.
124
+ if (sameRestoredIdentity(restored, identity)) addWireIdentity(wireName, identity.kind, true);
125
+ }
126
+ return keys;
127
+ }
128
+
129
+ type ToolSelection =
130
+ | { readonly kind: "unrestricted" }
131
+ | { readonly kind: "deny_all" }
132
+ | { readonly kind: "allow"; readonly identities: ReadonlySet<string> };
133
+
134
+ const UNRESTRICTED: ToolSelection = { kind: "unrestricted" };
135
+
136
+ /**
137
+ * Read the selector only where it states a client-call boundary.
138
+ *
139
+ * `auto`, `required` and an absent selector restrict nothing. A hosted selector
140
+ * (`{ type: "web_search" }`) forces a tool the PROVIDER runs and does not describe the client
141
+ * calls this turn may contain, so it is left alone rather than read as a deny-all: a false
142
+ * refusal would drop a call the caller could have executed.
143
+ */
144
+ function toolSelection(body: Record<string, unknown>): ToolSelection {
145
+ const choice = body.tool_choice;
146
+ if (choice === "none") return { kind: "deny_all" };
147
+ if (!isPlainObject(choice)) return UNRESTRICTED;
148
+ if (choice.type === "allowed_tools") {
149
+ const identities = new Set<string>();
150
+ if (!Array.isArray(choice.tools)) return { kind: "allow", identities };
151
+ for (const entry of choice.tools) {
152
+ const identity = selectorIdentity(entry);
153
+ if (identity) identities.add(identityKey(identity));
154
+ }
155
+ // An allow-list carrying no client tool — emptied by normalization, or hosted entries only —
156
+ // still bounds this turn: it allows no client call.
157
+ return { kind: "allow", identities };
158
+ }
159
+ if (choice.type === "function" || choice.type === "custom") {
160
+ const identity = selectorIdentity(choice);
161
+ return {
162
+ kind: "allow",
163
+ identities: new Set(identity ? [identityKey(identity)] : []),
164
+ };
165
+ }
166
+ return UNRESTRICTED;
167
+ }
168
+
169
+ export type RequestToolScope = {
170
+ /**
171
+ * The name a client call is refused under, or undefined when this request permits it.
172
+ * A nameless call type is not answered here: only the declaration guard knows those.
173
+ */
174
+ forbiddenClientToolCallName(item: Record<string, unknown>): string | undefined;
175
+ };
176
+
177
+ /**
178
+ * The client-call boundary this request states, or undefined when it states none.
179
+ *
180
+ * Returning undefined for an unrestricted request keeps every ordinary turn on the path it
181
+ * already had: a caller that selected nothing gets no new refusal.
182
+ */
183
+ export function requestToolScope(
184
+ body: unknown,
185
+ correspondence: RequestToolScopeCorrespondence = {},
186
+ ): RequestToolScope | undefined {
187
+ if (!isPlainObject(body)) return undefined;
188
+ const selection = toolSelection(body);
189
+ // A readable catalog that declares no client-executable name is authoritative, exactly as it is
190
+ // for the declaration guard: an explicit empty list denies every client call. An absent catalog
191
+ // says nothing — a passthrough request may omit `tools` and still receive a call the client
192
+ // understands.
193
+ const catalogDeniesClientCalls = hasExplicitWireToolCatalog(body)
194
+ && collectDeclaredWireToolNames(body).size === 0;
195
+ if (selection.kind === "unrestricted" && !catalogDeniesClientCalls) return undefined;
196
+ return {
197
+ forbiddenClientToolCallName(item: Record<string, unknown>): string | undefined {
198
+ if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) {
199
+ return undefined;
200
+ }
201
+ const restored = callIdentity(item);
202
+ const reported = restored?.name;
203
+ if (reported === undefined) return undefined;
204
+ if (catalogDeniesClientCalls || selection.kind === "deny_all") return reported;
205
+ if (selection.kind === "allow") {
206
+ const candidates = outboundCallIdentities(item, correspondence);
207
+ return [...candidates].some(candidate => selection.identities.has(candidate))
208
+ ? undefined
209
+ : reported;
210
+ }
211
+ return undefined;
212
+ },
213
+ };
214
+ }
@@ -1,4 +1,9 @@
1
- import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, wireToolInnerName } from "../responses/tool-name-aliases";
1
+ import {
2
+ collectAmbiguousDottedAliases,
3
+ dottedAliasIsUnambiguous,
4
+ isSchemaValidResponsesToolName,
5
+ wireToolInnerName,
6
+ } from "../responses/tool-name-aliases";
2
7
  import {
3
8
  dottedToolName,
4
9
  NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES,
@@ -8,7 +13,10 @@ import {
8
13
  import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
9
14
 
10
15
  /** Item types the client executes through a request-declared wire name. */
11
- const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]);
16
+ export const CLIENT_EXECUTED_CALL_TYPES: ReadonlySet<string> = new Set([
17
+ "function_call",
18
+ "custom_tool_call",
19
+ ]);
12
20
  /** Codex groups ordinary top-level tools here; unlike an MCP namespace, it has no wire prefix. */
13
21
  const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
14
22
 
@@ -512,6 +520,31 @@ export function normalizeDefaultNamespaceInItem(
512
520
  return { value: { ...item, name: bare }, changed: true };
513
521
  }
514
522
  }
523
+ // Authorization and emission were reading two different resolvers for the same question.
524
+ // `undeclaredNameInItem` resolves an emitted name through `normalizeDeclaredToolName`, which
525
+ // maps a `default.`-prefixed code-mode helper onto the declared `exec` (#4412) as well as a
526
+ // `default.`-prefixed bare tool (#4176); the rewrite above learned only the second case. So a
527
+ // routed Muse turn under a code-mode catalog had `default.view_image` ACCEPTED as `exec` and
528
+ // then relayed under the name the upstream schema rejects. Codex stored
529
+ // `{"type":"function_call","name":"default.view_image"}`, answered "unsupported call", and
530
+ // every later replay of that history — a side chat, a compaction — was refused on
531
+ // `input[N].name` for the lifetime of the conversation (#5095).
532
+ //
533
+ // Emit the name the guard authorized rather than a second, weaker opinion about it. The two
534
+ // must agree: a name good enough to admit is the name the client has to receive.
535
+ //
536
+ // Gated on the name actually being unusable, so this branch cannot touch a name the upstream
537
+ // accepts whatever the resolver would have said about it. The routed-custom-tool and
538
+ // namespace restores run earlier in the same chain and already rewrite the shapes they own,
539
+ // which leaves this as the boundary check for the names no restore claimed.
540
+ if (!isSchemaValidResponsesToolName(name)) {
541
+ const authorized = normalizeDeclaredToolName(name, declared, declaredBare);
542
+ if (
543
+ authorized !== name
544
+ && declared.has(authorized)
545
+ && isSchemaValidResponsesToolName(authorized)
546
+ ) return { value: { ...item, name: authorized }, changed: true };
547
+ }
515
548
  }
516
549
  return { value: item, changed: false };
517
550
  }
@@ -25,6 +25,9 @@ import { codexAccountNamespaceForModel } from "../codex/account-namespace-match"
25
25
  import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models";
26
26
  import { isCodexReserveRequestEligible } from "../codex/loopback-target";
27
27
  import type { DataPlaneAdmission } from "./auth-cors";
28
+ import {
29
+ admissionScopeDenial,
30
+ } from "./admission-model-scope";
28
31
  import { formatCodexProviderForLog } from "../codex/routing";
29
32
  import { signalWithTimeout } from "../lib/abort";
30
33
  import { readBoundedResponseBytes } from "../lib/bounded-body";
@@ -87,6 +90,11 @@ export async function handleSearch(
87
90
  if (!route.codexAccountId || route.codexAccountNamespace !== accountNamespace) {
88
91
  return formatErrorResponse(400, "invalid_request_error", "Invalid Codex account-qualified search model");
89
92
  }
93
+ // This branch resolves a model through the router and bills the account it
94
+ // names, so a scoped key is held to the same destination rule it is held
95
+ // to on the inference path.
96
+ const denial = admissionScopeDenial(config, admission, model, route);
97
+ if (denial) return denial;
90
98
  exactAccount = { accountId: route.codexAccountId, modelId: route.modelId };
91
99
  logCtx.provider = `${route.providerName}-${accountNamespace}`;
92
100
  logCtx.routeDecision = route.routeDecision;
@@ -108,7 +116,7 @@ export async function handleSearch(
108
116
  }
109
117
  const candidates = listOpenAiForwardSidecarCandidates(config);
110
118
  if (candidates.length === 0) {
111
- return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx);
119
+ return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx, admission);
112
120
  }
113
121
 
114
122
  let upstream: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
@@ -148,6 +156,22 @@ export async function handleSearch(
148
156
  throw err;
149
157
  }
150
158
 
159
+ if (!accountNamespace) {
160
+ // An unqualified search model is never routed: the caller's own string is
161
+ // relayed to whichever ChatGPT account this upstream resolved to, and that
162
+ // account is billed for it. The qualified branch above was already judged
163
+ // against the route it resolved, so it is not judged twice here.
164
+ const searchModel = typeof model === "string" && model.trim() ? model : undefined;
165
+ const denial = admissionScopeDenial(config, admission, searchModel, {
166
+ providerName: upstream.providerName,
167
+ modelId: searchModel,
168
+ });
169
+ if (denial) {
170
+ upstream.releaseProbeLease?.();
171
+ return denial;
172
+ }
173
+ }
174
+
151
175
  const headers: Record<string, string> = { "content-type": "application/json" };
152
176
  if (upstream.provider.headers) Object.assign(headers, upstream.provider.headers);
153
177
  for (const [name, value] of upstream.headers) headers[name] = value;
@@ -230,7 +230,7 @@ export function replaceSseDataPayload(block: string, payload: string): string {
230
230
  continue;
231
231
  }
232
232
  if (!replaced) {
233
- rewritten.push(`data: ${payload}`);
233
+ rewritten.push(...payload.split(/\r?\n/).map(line => `data: ${line}`));
234
234
  replaced = true;
235
235
  }
236
236
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Runtime owner for the opt-in usage-ledger size limit (#5063).
3
+ *
4
+ * The compactor in `src/usage/ledger-retention.ts` knows how to publish a smaller ledger safely.
5
+ * This is the part that decides when, and -- the half #5063 was missing -- what has to be
6
+ * discarded afterwards.
7
+ *
8
+ * Deleting rows from usage.jsonl invalidates three readers that do not watch the file: the
9
+ * 2,000-entry Logs ring, which otherwise keeps serving rows the ledger no longer has; the
10
+ * retained usage aggregate and failure projection, whose checkpoints now point past a boundary
11
+ * that moved; and the request-history index, whose source identity has changed. A compaction
12
+ * that skips any of them makes the dashboard disagree with the ledger, which is the disagreement
13
+ * this batch exists to remove.
14
+ */
15
+ import { currentUsageLogRevision, setUsageLedgerAppendHook } from "../usage/log";
16
+ import { enforceUsageLedgerSizeLimit } from "../usage/ledger-retention";
17
+ import { discardRetainedFailureProjection } from "../usage/failure-projection-cache";
18
+ import { rehydrateRequestLogsAfterLedgerReplacement } from "./request-log";
19
+ import type { UsageLedgerRetentionStatus } from "../usage/retention-contract";
20
+
21
+ let configuredMaxBytes: number | undefined;
22
+ let enforcing = false;
23
+
24
+ function invalidateLedgerReaders(): void {
25
+ // Ordered cheapest-first, and each guarded on its own: a projection that fails to discard
26
+ // must not stop the ring from being rebuilt, because the ring is the surface an operator is
27
+ // looking at while this happens.
28
+ try { discardRetainedFailureProjection(); } catch { /* rebuildable by construction */ }
29
+ void (async () => {
30
+ try {
31
+ const { discardRetainedUsageAggregate } = await import("./management/usage-aggregate-cache");
32
+ discardRetainedUsageAggregate();
33
+ } catch { /* rebuildable by construction */ }
34
+ try {
35
+ const { closeRequestHistoryIndex } = await import("../routing/history/indexer");
36
+ closeRequestHistoryIndex();
37
+ } catch { /* the index rebuilds from its own source-identity contract */ }
38
+ })();
39
+ try { rehydrateRequestLogsAfterLedgerReplacement(); } catch { /* the ring refills as rows arrive */ }
40
+ }
41
+
42
+ function enforceNow(): void {
43
+ // Re-entrancy guard, not a lock. The compaction itself runs inside the append call stack, and
44
+ // its own publication writes nothing through appendUsageEntry -- this exists so a future
45
+ // caller on that path cannot start a second pass over a file the first one is replacing.
46
+ if (enforcing) return;
47
+ enforcing = true;
48
+ try {
49
+ const result = enforceUsageLedgerSizeLimit(configuredMaxBytes);
50
+ if (result.kind === "replaced") invalidateLedgerReaders();
51
+ } catch (error) {
52
+ // Never fail a request because history could not be trimmed. The limit is not enforced and
53
+ // says so; the next append tries again from a fresh revision.
54
+ console.warn(
55
+ `[usage-retention] could not enforce the usage ledger size limit: ${error instanceof Error ? error.message : String(error)}`,
56
+ );
57
+ } finally {
58
+ enforcing = false;
59
+ }
60
+ }
61
+
62
+ /** Install or update the policy. `undefined` removes the hook entirely. */
63
+ export function setUsageLedgerRetention(maxBytes: number | undefined): void {
64
+ configuredMaxBytes = maxBytes;
65
+ setUsageLedgerAppendHook(maxBytes === undefined ? null : enforceNow);
66
+ }
67
+
68
+ export function usageLedgerRetentionStatus(): UsageLedgerRetentionStatus {
69
+ return {
70
+ ...(configuredMaxBytes !== undefined ? { maxBytes: configuredMaxBytes } : {}),
71
+ currentBytes: currentUsageLogRevision()?.size ?? 0,
72
+ };
73
+ }
@@ -13,7 +13,8 @@ import { resolveServiceListenPort, reportServiceServing } from "./health";
13
13
  import { platformOps, proxyStillLiveAfterStop, stopTrackedProxyForServiceCommand, installServiceSafely, installFreshWindowsSchedulerSafely, removeServiceInstallState, isServiceInstalled } from "./orchestration";
14
14
  import { repairService } from "./repair";
15
15
  import type { ServiceRepairVerb } from "./repair";
16
- import { TASK, plistPath, readServiceBackend } from "./state";
16
+ import { TASK, plistPath, readServiceBackend, releaseServiceOwner, resolveServiceOwnership } from "./state";
17
+ import { foreignServiceOwnerRefusal, unknownServiceOwnerRefusal } from "./repair";
17
18
  import type { ServiceBackend } from "./state";
18
19
  import { unitPath } from "./systemd";
19
20
  import { inspectWindowsSchedulerServiceStatus, schtasksErrorDetail, probeWindowsSchedulerTask } from "./windows-scheduler";
@@ -233,6 +234,15 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
233
234
  case "install":
234
235
  assertServiceEnvironmentMatchesInstall();
235
236
  assertServiceAuthEnvironment();
237
+ // The install may advance provenance, but it may take back only the owner it observed
238
+ // before touching the registration. A desktop successor that claims ownership while
239
+ // the install is running must survive the late release below.
240
+ const ownershipBeforeInstall = resolveServiceOwnership();
241
+ if (ownershipBeforeInstall.kind === "unknown") {
242
+ console.error(`❌ ${unknownServiceOwnerRefusal(ownershipBeforeInstall.reason, "install")}`);
243
+ process.exitCode = 1;
244
+ break;
245
+ }
236
246
  // A manually started proxy can still own the configured port while the service
237
247
  // registration is absent or unloaded. Stop both the registered manager and any
238
248
  // tracked standalone listener before loading the freshly written service assets.
@@ -258,6 +268,23 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
258
268
  process.exitCode = 1;
259
269
  break;
260
270
  }
271
+ // ONLY now. `install` is the verb that takes the runtime back, and it does so after the
272
+ // registration exists — releasing first meant a cancelled UAC prompt, a failed
273
+ // registration or an aborted cleanup left the retained npm registration looking
274
+ // CLI-owned, so the next incidental repair would reactivate it.
275
+ //
276
+ // `repair` and `restart` refuse under a foreign owner precisely because they run
277
+ // incidentally — from a tray helper, from `ocx update`, from a doctor suggestion — and
278
+ // undoing a takeover the user consented to must be something the user asked for.
279
+ {
280
+ const released = releaseServiceOwner(ownershipBeforeInstall, { allowRevisionAdvance: true });
281
+ if (released) {
282
+ console.log(
283
+ `ℹ️ The desktop app owned the background runtime (install ${released.installId}, `
284
+ + `consent generation ${released.consentGeneration}); this install took it back.`,
285
+ );
286
+ }
287
+ }
261
288
  // The wrapper was written moments ago in this process, so the configured port
262
289
  // and the baked one cannot have diverged yet — unlike `start`, which reads the
263
290
  // installed artifact instead.
@@ -269,15 +296,34 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
269
296
  // Same one-time marker and same guards (TTY, gh auth, agent deferral) apply.
270
297
  await maybeShowStarPrompt();
271
298
  break;
272
- case "start":
299
+ case "start": {
273
300
  // The installed launcher preserves the recorded CODEX_SQLITE_HOME: a
274
301
  // changed sqlite_home/CODEX_SQLITE_HOME/CODEX_HOME would start the service
275
302
  // on the recorded database while this shell resolves another, splitting
276
303
  // native Codex history between databases. Same guard `stop` already runs.
277
304
  assertServiceEnvironmentMatchesInstall();
305
+ // `start` activates the npm registration, so it refuses on the same terms repair does.
306
+ // The Windows tray starts the service automatically, which would otherwise put a second
307
+ // proxy beside the one the desktop app is running without anyone asking for it.
308
+ // `stop` and `uninstall` are deliberately NOT gated: they deactivate.
309
+ //
310
+ // Reported rather than thrown: the tray drives this through `runTrayProxyStart`, which
311
+ // does not catch, and a refusal is a decision rather than a crash.
312
+ const ownership = resolveServiceOwnership();
313
+ const refusal = ownership.kind === "unknown"
314
+ ? unknownServiceOwnerRefusal(ownership.reason, "start")
315
+ : ownership.kind === "owned" && ownership.ownership.owner !== "cli"
316
+ ? foreignServiceOwnerRefusal(ownership.ownership, "start")
317
+ : null;
318
+ if (refusal) {
319
+ console.error(`❌ ${refusal}`);
320
+ process.exitCode = 1;
321
+ break;
322
+ }
278
323
  ops.start();
279
324
  await reportServiceServing("started");
280
325
  break;
326
+ }
281
327
  case "stop": {
282
328
  assertServiceEnvironmentMatchesInstall();
283
329
  // Only stop what is actually installed. The unguarded call ran a real `launchctl unload`