@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -152,6 +152,8 @@ export function nativeChatSse(
152
152
  let sawFinish = false;
153
153
  let sawDone = false;
154
154
  let firstOutput = false;
155
+ let sawTextOutput = false;
156
+ let sawToolCalls = false;
155
157
  let settled = false;
156
158
  let cancelled = false;
157
159
  let cancelledBySignal = false;
@@ -264,7 +266,7 @@ export function nativeChatSse(
264
266
  const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
265
267
  const finished = choices.some(choice => isRec(choice) && typeof choice.finish_reason === "string" && choice.finish_reason.length > 0);
266
268
  if (finished) sawFinish = true;
267
- const hasProgress = choices.some(choice => {
269
+ const hasText = choices.some(choice => {
268
270
  if (!isRec(choice) || !isRec(choice.delta)) return false;
269
271
  const delta = choice.delta;
270
272
  return (typeof delta.content === "string" && delta.content.length > 0)
@@ -272,15 +274,22 @@ export function nativeChatSse(
272
274
  || (typeof delta.reasoning === "string" && delta.reasoning.length > 0)
273
275
  || (Array.isArray(delta.reasoning_details) && delta.reasoning_details.some(detail =>
274
276
  isRec(detail) && typeof detail.text === "string" && detail.text.length > 0))
275
- || (typeof delta.refusal === "string" && delta.refusal.length > 0)
276
- || (Array.isArray(delta.tool_calls) && delta.tool_calls.some(tool => {
277
- if (!isRec(tool)) return false;
278
- const fn = isRec(tool.function) ? tool.function : {};
279
- return (typeof tool.id === "string" && tool.id.length > 0)
280
- || (typeof fn.name === "string" && fn.name.length > 0)
281
- || (typeof fn.arguments === "string" && fn.arguments.length > 0);
282
- }));
277
+ || (typeof delta.refusal === "string" && delta.refusal.length > 0);
283
278
  });
279
+ const hasTool = choices.some(choice => {
280
+ if (!isRec(choice) || !isRec(choice.delta)) return false;
281
+ const delta = choice.delta;
282
+ return Array.isArray(delta.tool_calls) && delta.tool_calls.some(tool => {
283
+ if (!isRec(tool)) return false;
284
+ const fn = isRec(tool.function) ? tool.function : {};
285
+ return (typeof tool.id === "string" && tool.id.length > 0)
286
+ || (typeof fn.name === "string" && fn.name.length > 0)
287
+ || (typeof fn.arguments === "string" && fn.arguments.length > 0);
288
+ });
289
+ });
290
+ if (hasText) sawTextOutput = true;
291
+ if (hasTool) sawToolCalls = true;
292
+ const hasProgress = hasText || hasTool;
284
293
  if (hasProgress || finished) pullDeadline = performance.now() + stallMs;
285
294
  else if (performance.now() >= pullDeadline) throw stalled;
286
295
  if (!firstOutput && hasProgress) {
@@ -358,6 +367,14 @@ export function nativeChatSse(
358
367
  if (!sawDone) enqueue(controller, "data: [DONE]\n\n");
359
368
  settle(200);
360
369
  controller.close();
370
+ } else if (sawToolCalls) {
371
+ // Mid tool-call truncation must fail closed to prevent downstream executing partial arguments.
372
+ fail(controller, "upstream SSE ended mid tool call without a terminal signal — possible truncation", "upstream_sse_truncated");
373
+ } else if (sawTextOutput) {
374
+ // Upstream produced text or reasoning output but missed the terminal frame; recover cleanly with [DONE].
375
+ enqueue(controller, "data: [DONE]\n\n");
376
+ settle(200);
377
+ controller.close();
361
378
  } else {
362
379
  fail(controller, "upstream SSE ended before a terminal event", "upstream_sse_truncated");
363
380
  }
@@ -50,7 +50,7 @@ import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate
50
50
  import type { OcxProviderTransport } from "../providers/xai-transport";
51
51
  import type { RouteResult } from "../router";
52
52
  import type { OcxConfig, OcxProviderConfig } from "../types";
53
- import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers";
53
+ import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
54
54
  import { linkAbortSignal } from "./responses";
55
55
  import {
56
56
  addFinalRequestLog,
@@ -351,9 +351,15 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
351
351
  if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding);
352
352
  if (init.signal?.aborted) throw init.signal.reason;
353
353
  noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
354
- const dispatched = await ((activeProvider as OcxProviderTransport).fetch ?? execute)(request.url, applyUpstreamRecoveryInit({
355
- ...init, method: request.method, headers, body: request.body,
356
- }, transportRecovery));
354
+ // A reselected provider transport is still a physical send: the connection policy
355
+ // and manual-redirect ownership wrap the selected implementation (#4992).
356
+ const dispatched = await sendWithConnectionPolicy(
357
+ (activeProvider as OcxProviderTransport).fetch ?? execute,
358
+ request.url,
359
+ applyUpstreamRecoveryInit({
360
+ ...init, method: request.method, headers, body: request.body,
361
+ }, transportRecovery),
362
+ );
357
363
  if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
358
364
  return dispatched;
359
365
  },
@@ -790,6 +790,22 @@ async function handleClaudeMessagesWithBudget(
790
790
 
791
791
  if (!requestedModel) requestedModel = (anthropicBody as Rec).model as string;
792
792
  const stream = internalBody.stream === true;
793
+ /**
794
+ * This proxy's count of the prompt it is about to forward, computed at most once.
795
+ *
796
+ * Two readers want it and they want it under different rules. The usage log takes it as a
797
+ * floor only for estimated-usage adapters, because its merge is `max(reported, estimate)` and
798
+ * would otherwise overwrite real usage. `message_start` takes it whenever the upstream sent
799
+ * no confirmed usage before the first frame, where nothing is merged and the terminal
800
+ * `message_delta` still corrects it (#4857).
801
+ */
802
+ let requestTokenFloor: number | undefined;
803
+ const claudeRequestTokenFloor = (): number => {
804
+ if (requestTokenFloor === undefined) {
805
+ requestTokenFloor = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel);
806
+ }
807
+ return requestTokenFloor;
808
+ };
793
809
  // Routed adapters only support streamed turns; always stream internally and fold
794
810
  // the translated Anthropic SSE into a message JSON for non-streaming clients.
795
811
  internalBody.stream = true;
@@ -815,7 +831,7 @@ async function handleClaudeMessagesWithBudget(
815
831
  // accurate-usage adapters — the request-log merge is max(reported, estimate) and
816
832
  // would overwrite real usage (audit 133 R1#7).
817
833
  if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
818
- logCtx.usageLogInputTokens = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel);
834
+ logCtx.usageLogInputTokens = claudeRequestTokenFloor();
819
835
  }
820
836
  // Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make
821
837
  // every routed model look like a reasoning model to Claude clients, so a forced
@@ -988,7 +1004,13 @@ async function handleClaudeMessagesWithBudget(
988
1004
 
989
1005
  const contentType = response.headers.get("content-type") ?? "";
990
1006
  if (contentType.includes("text/event-stream") && response.body) {
991
- const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel, { translatorBudget });
1007
+ const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel, {
1008
+ translatorBudget,
1009
+ // Only a floor, and only for the first frame: an upstream that reports usage early wins
1010
+ // over it inside the translator, and the terminal `message_delta` carries the
1011
+ // authoritative count either way (#4857).
1012
+ inputTokenFloor: claudeRequestTokenFloor(),
1013
+ });
992
1014
  if (stream) {
993
1015
  return new Response(anthropicSse, {
994
1016
  status: 200,
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { extname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { browserSecurityHeaders } from "./auth-cors";
4
4
  import type { GuiSessionBootstrap } from "./gui-session";
5
5
 
@@ -18,6 +18,12 @@ const MIME_TYPES: Record<string, string> = {
18
18
  ".ico": "image/x-icon",
19
19
  };
20
20
 
21
+ /**
22
+ * Matches Vite's content-hashed bundle filenames (e.g. `index-B5r7LNHN.js` or `style-D5SiRo8X.css`).
23
+ * Unhashed static assets (e.g. `runtime-config.js` or unversioned icons) must not be cached immutably.
24
+ */
25
+ const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/;
26
+
21
27
  function findGuiDist(): string | null {
22
28
  const candidates = [
23
29
  join(import.meta.dir, "..", "..", "gui", "dist"),
@@ -146,6 +152,14 @@ export function serveSessionBootstrap(session: GuiSessionBootstrap): Response {
146
152
  return htmlDocumentResponse(html);
147
153
  }
148
154
 
155
+ /**
156
+ * Serves a GUI static file from the distribution directory.
157
+ * Returns a Response with MIME type and appropriate Cache-Control headers:
158
+ * - HTML files receive `no-store` to guarantee immediate bootstrap updates.
159
+ * - Content-hashed bundles under `assets/` receive 1-year `immutable` caching.
160
+ * - Non-hashed static files (e.g. favicon, unhashed assets) receive `no-cache` for prompt revalidation.
161
+ * Returns null if the file cannot be found or resolved.
162
+ */
149
163
  export function serveGuiFile(
150
164
  pathname: string,
151
165
  guiDist = findGuiDist(),
@@ -170,11 +184,31 @@ export function serveGuiFile(
170
184
  const ext = extname(filePath);
171
185
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
172
186
  if (ext === ".html") return htmlResponse(filePath, session, runtimeRole, managementAuthRequired);
187
+
188
+ const root = resolve(guiDist);
189
+ // "assets/" matches Vite's default assetsDir (gui/vite.config.ts). If that value is
190
+ // ever changed, update this prefix to match. filePath is already resolve()-normalized
191
+ // by resolveGuiFilePath, so rel cannot contain ".." fragments.
192
+ //
193
+ // Immutable 1-year caching is only applied when the file is under assets/ AND its basename
194
+ // matches Vite's content-hash pattern (e.g. index-B5r7LNHN.js). Any unhashed asset under
195
+ // assets/ (e.g. runtime-config.js) or elsewhere falls back to no-cache so clients revalidate,
196
+ // whereas index.html uses no-store above to avoid storing any bootstrap state.
197
+ const rel = relative(root, filePath).replace(/\\/g, "/");
198
+ const isHashedAsset = rel.startsWith("assets/") && HASHED_ASSET_PATTERN.test(basename(filePath));
199
+ const cacheControl = isHashedAsset
200
+ ? "public, max-age=31536000, immutable"
201
+ : "no-cache";
202
+
173
203
  // Snapshot bytes before returning the response. Bun.file is lazy: if gui/dist is replaced
174
204
  // after Bun frames the response but before the stream finishes, its Content-Length can
175
205
  // describe the old file while the body comes from the new one (#2792).
176
206
  return new Response(readFileSync(filePath), {
177
- headers: { "Content-Type": contentType, ...browserSecurityHeaders() },
207
+ headers: {
208
+ "Content-Type": contentType,
209
+ "Cache-Control": cacheControl,
210
+ ...browserSecurityHeaders(),
211
+ },
178
212
  });
179
213
  }
180
214
 
@@ -0,0 +1,187 @@
1
+ import {
2
+ MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
3
+ MAX_DECOMPRESSED_BODY_BYTES,
4
+ resolveInboundBodyLimitBytes,
5
+ } from "./request-decompress";
6
+ import { withCors, type RequestPolicyView } from "./auth-cors";
7
+ import { codexCompatibleUrl } from "../codex/context-compat";
8
+ import { addFinalRequestLog, markLocalRequestLogRefusal } from "./request-log";
9
+ import type { WorkflowRefusalLog } from "./workflow-refusal";
10
+
11
+ /** Admission units, not a bound on process RSS or the size of parsed object graphs. */
12
+ export const MAX_CONCURRENT_INBOUND_BODY_BYTES = MAX_CONFIGURABLE_INBOUND_BODY_BYTES;
13
+ let reservedInboundBodyBytes = 0;
14
+
15
+ // Match the HTTP routes whose JSON readers use maxInboundBodyBytes. Keep the
16
+ // current image/search/count_tokens support; management, audio, context relay,
17
+ // and WebSocket frames have separate limits and are not part of this gate.
18
+ // Exported so the contract tests classify the dispatcher's own route list
19
+ // instead of copying it: a new loopback route has to land in one of these two
20
+ // sets, or `tests/server/server-request-body-size.test.ts` fails.
21
+ export const CONFIGURABLE_JSON_BODY_ROUTES: ReadonlySet<string> = new Set([
22
+ "/v1/responses",
23
+ "/v1/responses/compact",
24
+ "/v1/chat/completions",
25
+ "/v1/messages",
26
+ "/v1/messages/count_tokens",
27
+ "/v1/images/generations",
28
+ "/v1/images/edits",
29
+ "/v1/alpha/search",
30
+ ]);
31
+
32
+ /**
33
+ * Loopback routes deliberately outside this gate. Audio uploads, realtime/live
34
+ * control and the model list do not read JSON under `maxInboundBodyBytes`, so
35
+ * reserving that allowance for them would refuse traffic the limit never covered.
36
+ */
37
+ export const UNGATED_LOOPBACK_ROUTES: ReadonlySet<string> = new Set([
38
+ "/v1/audio/transcriptions",
39
+ "/v1/audio/transcriptions/stream",
40
+ "/v1/models",
41
+ "/v1/realtime",
42
+ "/v1/realtime/calls",
43
+ "/v1/live",
44
+ ]);
45
+
46
+ export class InboundBodyCapacityError extends Error {
47
+ constructor() {
48
+ super("OpenCodex inbound request body capacity is temporarily exhausted. Retry after the current request completes.");
49
+ this.name = "InboundBodyCapacityError";
50
+ }
51
+ }
52
+
53
+ function cancelUnreadBody(req: Request, reason: unknown): void {
54
+ if (!req.body || req.body.locked) return;
55
+ try {
56
+ // A tee's cancellation can wait for the other branch. Refusal must not wait
57
+ // for a peer that has not finished uploading, or for an unconsumed clone.
58
+ void req.body.cancel(reason).catch(() => undefined);
59
+ } catch { /* Non-standard streams may throw synchronously. */ }
60
+ }
61
+
62
+ function capacityResponse(pathname: string, error: InboundBodyCapacityError): Response {
63
+ const anthropic = pathname === "/v1/messages" || pathname === "/v1/messages/count_tokens";
64
+ return Response.json({
65
+ ...(anthropic ? { type: "error" } : {}),
66
+ error: {
67
+ type: anthropic ? "overloaded_error" : "server_error",
68
+ code: "server_busy",
69
+ message: error.message,
70
+ },
71
+ }, { status: 503, headers: { "Retry-After": "1" } });
72
+ }
73
+
74
+ function retainUntilResponseSettles(response: Response, release: () => void): Response {
75
+ if (!response.body) {
76
+ release();
77
+ return response;
78
+ }
79
+ const reader = response.body.getReader();
80
+ let cancelling = false;
81
+ let finalized = false;
82
+ const finalize = (): void => {
83
+ if (finalized) return;
84
+ finalized = true;
85
+ try { reader.releaseLock(); } finally { release(); }
86
+ };
87
+ const body = new ReadableStream<Uint8Array>({
88
+ async pull(controller) {
89
+ try {
90
+ const result = await reader.read();
91
+ // cancel() can resolve a pending read as EOF before the producer's
92
+ // asynchronous cancellation completes. Only cancel owns release then.
93
+ if (cancelling) return;
94
+ if (result.done) {
95
+ finalize();
96
+ controller.close();
97
+ } else {
98
+ controller.enqueue(result.value);
99
+ }
100
+ } catch (error) {
101
+ if (cancelling) return;
102
+ finalize();
103
+ controller.error(error);
104
+ }
105
+ },
106
+ async cancel(reason) {
107
+ cancelling = true;
108
+ try { await reader.cancel(reason); } finally { finalize(); }
109
+ },
110
+ }, { highWaterMark: 0 });
111
+ // This is the outermost HTTP wrapper, after protocol conversion, relay
112
+ // preflight and request logging; no internal relay marker is consumed later.
113
+ return new Response(body, {
114
+ status: response.status,
115
+ statusText: response.statusText,
116
+ headers: response.headers,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Own one raised-limit HTTP request from before body reading through response
122
+ * EOF/error or settled cancellation. Call only at the admitted HTTP boundary,
123
+ * after auth/origin checks, not from readers or internal combo/translation hops.
124
+ * pathname is the same canonical pathname used by the server's dispatcher.
125
+ */
126
+ export async function withRaisedInboundBodyAdmission(
127
+ req: Request,
128
+ pathname: string,
129
+ configuredLimit: number | undefined,
130
+ work: () => Promise<Response>,
131
+ onRefusal: (response: Response) => Response = response => response,
132
+ ): Promise<Response> {
133
+ const maxBytes = resolveInboundBodyLimitBytes(configuredLimit);
134
+ if (req.method !== "POST" || !CONFIGURABLE_JSON_BODY_ROUTES.has(pathname)
135
+ || maxBytes <= MAX_DECOMPRESSED_BODY_BYTES || req.signal.aborted) {
136
+ return work();
137
+ }
138
+ const declared = req.headers.get("content-length");
139
+ const declaredBytes = declared === null || declared.trim() === "" ? NaN : Number(declared);
140
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
141
+ // The existing bounded reader rejects this before allocating. Let its
142
+ // protocol-specific 413 win over temporary capacity refusal, unchanged.
143
+ return work();
144
+ }
145
+ if (maxBytes > MAX_CONCURRENT_INBOUND_BODY_BYTES - reservedInboundBodyBytes) {
146
+ const error = new InboundBodyCapacityError();
147
+ cancelUnreadBody(req, error);
148
+ return onRefusal(capacityResponse(pathname, error));
149
+ }
150
+ reservedInboundBodyBytes += maxBytes;
151
+ let released = false;
152
+ const release = (): void => {
153
+ if (released) return;
154
+ released = true;
155
+ reservedInboundBodyBytes -= maxBytes;
156
+ };
157
+ try {
158
+ return retainUntilResponseSettles(await work(), release);
159
+ } catch (error) {
160
+ release();
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The admitted HTTP call site. Refusal is recorded as a local terminal and
167
+ * carries the receiving listener's CORS headers, so a browser dashboard can
168
+ * read it. No request content is read or logged on this path.
169
+ */
170
+ export function runAdmittedBodyWork(
171
+ req: Request,
172
+ policy: RequestPolicyView,
173
+ configuredLimit: number | undefined,
174
+ work: () => Promise<Response>,
175
+ refusalLog?: WorkflowRefusalLog,
176
+ ): Promise<Response> {
177
+ return withRaisedInboundBodyAdmission(req, codexCompatibleUrl(req.url).pathname, configuredLimit, work, refused => {
178
+ if (refusalLog) {
179
+ markLocalRequestLogRefusal(refusalLog.logCtx, "server_busy");
180
+ refusalLog.logCtx.errorCode = "server_busy";
181
+ addFinalRequestLog(refusalLog.requestId, refusalLog.start, refusalLog.logCtx, refused.status, {
182
+ closeReason: "terminal",
183
+ });
184
+ }
185
+ return withCors(refused, req, policy);
186
+ });
187
+ }
@@ -1,3 +1,7 @@
1
+ import { nativeSteeringUnavailableReason, nativeResponseControlMode, type NativeResponseControl } from "../responses/native-response-control";
2
+ import { NativeInjectionChannel } from "../responses/native-injection";
3
+ import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering";
4
+ import { createNativeSteeringLogObserver } from "../responses/native-steering-log";
1
5
  import type { Server, ServerWebSocket } from "bun";
2
6
  import {
3
7
  LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
@@ -188,11 +192,47 @@ export function createWebsocketHandler(ctx: ServeOptionsContext) {
188
192
  } catch {
189
193
  return; // text-only contract; ignore unparseable frames
190
194
  }
195
+ if (frame.type === "response.inject" || frame.type === "response.steer" || (frame.type === "response.create" && ws.data.nativeControl)) {
196
+ try {
197
+ if (frame.type === "response.inject") {
198
+ if (!ws.data.nativeControl?.inject) throw new NativeSteeringError("injection_not_supported", "Native injection is disabled or unavailable on this route.");
199
+ ws.data.nativeControl.inject(frame);
200
+ return;
201
+ }
202
+ if (frame.type === "response.steer") {
203
+ if (!ws.data.nativeControl) throw new NativeSteeringError("steering_not_supported", ws.data.nativeSteeringUnavailable ?? "Native steering transport is unavailable; the route may be unsupported or using HTTP fallback.");
204
+ ws.data.nativeControl.steer(frame);
205
+ return;
206
+ }
207
+ if (ws.data.nativeControl?.continue(frame)) return;
208
+ } catch (error) {
209
+ sendJsonFrame(ws, buildWsErrorFrame(400, {
210
+ type: "invalid_request_error",
211
+ code: error instanceof NativeSteeringError ? error.code : "native_steering_error",
212
+ message: error instanceof NativeSteeringError ? error.message : "Native steering transport failed; delivery may be unknown. Do not automatically replay input.",
213
+ }));
214
+ return;
215
+ }
216
+ }
191
217
  if (frame.type === "response.processed") return; // ack — no-op
192
218
  if (frame.type !== "response.create") return;
193
219
  markActivity("ws response.create");
194
220
 
221
+ let nativeControl: NativeResponseControl | undefined;
222
+ try {
223
+ const idleMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec)
224
+ ? Math.max(1, config.stallTimeoutSec) * 1000 : 300_000;
225
+ const mode = nativeResponseControlMode(frame, config);
226
+ nativeControl = mode === "injection" ? new NativeInjectionChannel(frame, idleMs)
227
+ : mode === "steering" ? new NativeSteeringChannel(frame, idleMs) : undefined;
228
+ } catch {
229
+ sendJsonFrame(ws, buildWsErrorFrame(400, { type: "invalid_request_error", message: "Invalid native steering request settings" }));
230
+ return;
231
+ }
195
232
  ws.data.cancel?.();
233
+ // A superseded turn must not keep ownership during warmup or refusal.
234
+ ws.data.nativeControl = undefined;
235
+ ws.data.nativeSteeringUnavailable = nativeSteeringUnavailableReason(frame, config.codexNativeSteering);
196
236
  const turnId = (ws.data.turnId ?? 0) + 1;
197
237
  ws.data.turnId = turnId;
198
238
  const isCurrent = () => ws.data.turnId === turnId;
@@ -227,6 +267,8 @@ export function createWebsocketHandler(ctx: ServeOptionsContext) {
227
267
  return;
228
268
  }
229
269
 
270
+ // Only a genuinely admitted turn may receive steering or continuations.
271
+ ws.data.nativeControl = nativeControl;
230
272
  const payload: Record<string, unknown> = { ...frame };
231
273
  delete payload.type;
232
274
  turnAdmissionLease.bindAbortController(turnAbort);
@@ -267,6 +309,7 @@ export function createWebsocketHandler(ctx: ServeOptionsContext) {
267
309
  ...(wsAdmission ? { admission: wsAdmission } : {}),
268
310
  forceEmptyResponseId: true,
269
311
  inboundTransport: "websocket",
312
+ nativeControl,
270
313
  abortSignal: turnAbort.signal,
271
314
  turnAdmissionLease,
272
315
  onFirstOutput: () => recordFirstOutput(logCtx, start),
@@ -277,7 +320,10 @@ export function createWebsocketHandler(ctx: ServeOptionsContext) {
277
320
  },
278
321
  });
279
322
  await sendResponseToWebSocket(ws, response, isCurrent, {
280
- onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
323
+ untilEof: nativeControl?.relayActive === true,
324
+ onSsePayload: nativeControl?.relayActive
325
+ ? createNativeSteeringLogObserver(logCtx, () => recordFirstOutput(logCtx, start))
326
+ : payload => inspectResponseLogSsePayload(logCtx, payload),
281
327
  onTerminal: status => {
282
328
  terminalRecorder?.(status, logCtx.terminalHttpStatus);
283
329
  finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
@@ -313,6 +359,7 @@ export function createWebsocketHandler(ctx: ServeOptionsContext) {
313
359
  }
314
360
  } finally {
315
361
  turnAdmissionLease.release();
362
+ if (ws.data.nativeControl === nativeControl) ws.data.nativeControl = undefined;
316
363
  if (!logged && turnAbort.signal.aborted) finalizeLog(499);
317
364
  if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
318
365
  }
@@ -1,5 +1,6 @@
1
1
  import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation";
2
2
  import { AuxiliaryListenerBindError } from "./ports";
3
+ import { runAdmittedBodyWork } from "./inbound-body-admission";
3
4
  import {
4
5
  buildWarmupCompletionFrames,
5
6
  buildWsErrorFrame,
@@ -106,7 +107,6 @@ export {
106
107
  unregisterTurn,
107
108
  } from "./lifecycle";
108
109
  import {
109
- addFinalRequestLog,
110
110
  hydrateRequestLogsFromDisk,
111
111
  httpStatusForRequestLogTerminal,
112
112
  inspectResponseLogSsePayload,
@@ -116,8 +116,8 @@ import {
116
116
  type RequestLogEntry,
117
117
  } from "./request-log";
118
118
  import { sessionLaneIdFromRequest } from "./request-log-conversation";
119
- import { admitWorkflowTurn, type WorkflowLane } from "../lib/workflow-budget";
120
- import { workflowRefusalResponse, type WorkflowRefusalLog } from "./workflow-refusal";
119
+ import { configureSharedSpendLedger, spendPolicyFromConfig } from "../lib/spend-reservation-ledger";
120
+ import { admitHttpWorkflowTurn, workflowDecisionRefusalResponse, type WorkflowRefusalLog } from "./workflow-refusal";
121
121
  export {
122
122
  addFinalRequestLog,
123
123
  filterRequestLogs,
@@ -181,7 +181,7 @@ import {
181
181
  type NativeMainStartupLifecycle,
182
182
  } from "../codex/native-profile-startup";
183
183
  import { EXTERNAL_CALL_PREFIX, LiveCallBindings } from "./live-call-bindings";
184
- import { codexCompatibleUrl, contextEndpoint, contextRelayActivated } from "../codex/context-compat";
184
+ import { contextEndpoint, contextRelayActivated } from "../codex/context-compat";
185
185
  import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api";
186
186
  import {
187
187
  createManagementSessionControl,
@@ -225,7 +225,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
225
225
  warnPlaintextV2AgentMessagesStartup(config);
226
226
  warnAgentTaskRecoveryStartup(config);
227
227
  setLiveStateStoreConfig(config);
228
- applyProxyEnv(config);
228
+ applyProxyEnv(config, true);
229
229
  assertServerAuthConfig(config);
230
230
  const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
231
231
  const managementSessionControl = createManagementSessionControl(managementAuth);
@@ -299,6 +299,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
299
299
  registerAppOwnedMemorySweepFallback();
300
300
  configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(config.appOwnedMemoryBudgetMb));
301
301
  enforceAppOwnedMemoryBudget();
302
+ // Operator token ceilings (#4546). Applied before the listener binds: the shared ledger is
303
+ // built on the first reservation, and a request arriving before this ran would build it with
304
+ // no policy and enforce nothing. An absent `spend` section resolves to the unconfigured
305
+ // default, which refuses nothing and opens no journal -- so on an install that never wrote
306
+ // the key this line changes no behaviour at all.
307
+ configureSharedSpendLedger(spendPolicyFromConfig(config.spend));
302
308
  registerCodexCooldownRecoveryProbeWorker(config);
303
309
  // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly
304
310
  // tick for daily/weekly; startup evaluation is fire-and-forget after listen.
@@ -498,28 +504,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
498
504
  ): Promise<Response> {
499
505
  const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
500
506
  if (!lease) return serverBusyResponse(req, "active turns", policy);
501
- // A fan-out shares the conversation it serves. Without a reserve, a worker burst takes every
502
- // slot under its own root and the interactive turn that started it waits behind its own
503
- // children. A request that names a parent is treated as that fan-out; a top-level request is
504
- // the conversation and may use the reserved slots.
505
- const workflowRootId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
506
- const workflowThreadId = req.headers.get("thread-id")?.trim() || undefined;
507
- const workflowLane: WorkflowLane = workflowRootId !== undefined
508
- && workflowThreadId !== undefined
509
- && workflowThreadId !== workflowRootId
510
- ? "worker"
511
- : "interactive";
512
- const workflow = admitWorkflowTurn(workflowRootId, workflowLane, undefined, workflowThreadId);
507
+ // Root, lane, and the refusal that follows from them, all live in ./workflow-refusal.
508
+ const workflow = admitHttpWorkflowTurn(req.headers);
513
509
  if (workflow && !workflow.admitted) {
514
510
  lease.release();
515
511
  // withCors, because without Access-Control-Allow-Origin the exposed refusal header is
516
512
  // still unreadable to a browser dashboard -- which made exposing it pointless.
517
- return withCors(workflowRefusalResponse(workflow.reason, undefined, refusalLog), req, policy);
513
+ return withCors(workflowDecisionRefusalResponse(workflow, undefined, refusalLog), req, policy);
518
514
  }
519
515
  const releaseWorkflow = (): void => { if (workflow?.admitted) workflow.lease.release(); };
520
516
  let response: Response;
521
517
  try {
522
- response = await work(lease);
518
+ response = await runAdmittedBodyWork(req, policy, config.maxInboundBodyBytes, () => work(lease), refusalLog);
523
519
  } catch (error) {
524
520
  releaseWorkflow();
525
521
  lease.release();
@@ -1,7 +1,6 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import { isWildcardHostname } from "../../codex/loopback-target";
3
- import { localInferenceDestination } from "../../lib/local-destinations";
4
- import { probeHostname } from "../proxy-liveness";
3
+ import { localCredentialDestinationHostname, localInferenceDestination } from "../../lib/local-destinations";
5
4
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers-destination";
6
5
  import { LIVE_AUDIO_MODEL, TRANSCRIPTION_MODEL } from "../audio-upstream";
7
6
 
@@ -95,7 +94,7 @@ export function resolveApiAccessBaseUrl(
95
94
  const port = config.port ?? 10100;
96
95
 
97
96
  if (!isWildcardBindHost(config.hostname)) {
98
- return `http://${probeHostname(config.hostname)}:${port}/v1`;
97
+ return `http://${localCredentialDestinationHostname(config.hostname)}:${port}/v1`;
99
98
  }
100
99
 
101
100
  const fromOrigin = opts.requestOrigin ? originBaseUrl(opts.requestOrigin) : null;
@@ -140,7 +139,7 @@ export function resolveApiAccessDisplayHost(
140
139
  opts: BuildApiAccessEndpointsOptions = {},
141
140
  ): string {
142
141
  if (!isWildcardBindHost(configHostname)) {
143
- return probeHostname(configHostname);
142
+ return localCredentialDestinationHostname(configHostname);
144
143
  }
145
144
  try {
146
145
  return new URL(resolveApiAccessBaseUrl({ hostname: configHostname, port: 10100 }, opts)).hostname