@bitkyc08/opencodex 2.52.0 → 2.53.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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-BlO4Yl6q.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -183,7 +183,10 @@ export type SseTerminalOutputBoundary = {
183
183
  * terminal, and drops every later block/byte. A premature [DONE] is held until
184
184
  * a terminal arrives so clean EOF can synthesize one terminal and one sentinel.
185
185
  */
186
- export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
186
+ export function createSseTerminalOutputBoundary(
187
+ options?: CodexSafetyBufferingFilterOptions,
188
+ ): SseTerminalOutputBoundary {
189
+ const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true;
187
190
  const decoder = new TextDecoder();
188
191
  const encoder = new TextEncoder();
189
192
  const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES);
@@ -207,15 +210,22 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
207
210
  // behind EOF, so its log context cannot determine the outgoing terminal.
208
211
  const message = boundedBareUpstreamErrorMessage(parsed);
209
212
  if (message !== undefined) upstreamError = message;
213
+ const safetyBuffering = dropSafetyBuffering && parsed !== undefined
214
+ ? codexSafetyBufferingBlockAction(parsed) : "keep";
215
+ if (safetyBuffering === "drop") continue;
210
216
  const policyError = parsed !== undefined && isPolicyRewriteType(parsed)
211
217
  ? cyberPolicyTerminalError(parsed)
212
218
  : undefined;
213
- const outboundBlock = policyError
214
- ? encoder.encode(rewritePolicyTerminalBlock(
215
- decoder.decode(frame.block),
216
- policyFailurePayload(policyError, parsed),
217
- ))
219
+ const policyPayload = policyError ? policyFailurePayload(policyError, parsed) : undefined;
220
+ let outboundBlock = policyPayload !== undefined
221
+ ? encoder.encode(rewritePolicyTerminalBlock(decoder.decode(frame.block), policyPayload))
218
222
  : frame.block;
223
+ if (safetyBuffering === "strip") {
224
+ outboundBlock = encoder.encode(stripCodexSafetyBufferingField(
225
+ decoder.decode(outboundBlock),
226
+ policyPayload !== undefined ? parseSsePayload(policyPayload) : parsed,
227
+ ));
228
+ }
219
229
  if (isDone) {
220
230
  done = true;
221
231
  if (responsesTerminal) {
@@ -287,11 +297,11 @@ export function relaySseWithFailedTail(
287
297
  body: ReadableStream<Uint8Array>,
288
298
  upstream: AbortController,
289
299
  onClientGone?: (reason?: unknown) => void,
290
- opts?: { upstreamError?: string },
300
+ opts?: { upstreamError?: string; terminalBoundary?: CodexSafetyBufferingFilterOptions },
291
301
  ): ReadableStream<Uint8Array> {
292
302
  const reader = body.getReader();
293
303
  const encoder = new TextEncoder();
294
- const terminalBoundary = createSseTerminalOutputBoundary();
304
+ const terminalBoundary = createSseTerminalOutputBoundary(opts?.terminalBoundary);
295
305
  let closed = false;
296
306
  const relayChunk = (
297
307
  controller: ReadableStreamDefaultController<Uint8Array>,
@@ -468,6 +478,29 @@ function isPolicyRewriteType(parsed: unknown): boolean {
468
478
  return type === "response.failed" || type === "response.incomplete" || type === "error";
469
479
  }
470
480
 
481
+ /**
482
+ * Codex emits its safety-buffering hint in the SSE body as well as in headers:
483
+ * a `response.metadata` event whose `metadata.type` is `safety_buffering`, or a
484
+ * `safety_buffering` field on another event. The metadata event is dropped whole;
485
+ * the field is stripped so the carrying event is otherwise relayed unchanged.
486
+ */
487
+ function codexSafetyBufferingBlockAction(parsed: unknown): "keep" | "drop" | "strip" {
488
+ const root = asJsonRecord(parsed);
489
+ if (!root) return "keep";
490
+ if (root.type === "response.metadata") {
491
+ const metadata = asJsonRecord(root.metadata);
492
+ if (metadata?.type === "safety_buffering") return "drop";
493
+ }
494
+ return Object.hasOwn(root, "safety_buffering") ? "strip" : "keep";
495
+ }
496
+
497
+ function stripCodexSafetyBufferingField(block: string, parsed: unknown): string {
498
+ const root = asJsonRecord(parsed);
499
+ if (!root) return block;
500
+ const { safety_buffering: _safetyBuffering, ...rest } = root;
501
+ return replaceSseDataPayload(block, JSON.stringify(rest));
502
+ }
503
+
471
504
  function rewritePolicyTerminalBlock(block: string, payload: string): string {
472
505
  const newline = block.includes("\r\n") ? "\r\n" : "\n";
473
506
  const rewritten = replaceSseDataPayload(block, payload);
@@ -1461,7 +1494,31 @@ export function consumeForResponseLogMetadata(
1461
1494
  * body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough.
1462
1495
  * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim.
1463
1496
  */
1464
- export function sanitizePassthroughHeaders(upstream: Headers): Headers {
1497
+ export const CODEX_SAFETY_BUFFERING_HEADERS = [
1498
+ "x-codex-safety-buffering-enabled",
1499
+ "x-codex-safety-buffering-faster-model",
1500
+ ] as const;
1501
+
1502
+ const CODEX_SAFETY_BUFFERING_HEADER_SET: ReadonlySet<string> = new Set(CODEX_SAFETY_BUFFERING_HEADERS);
1503
+
1504
+ export interface CodexSafetyBufferingFilterOptions {
1505
+ /**
1506
+ * Drop Codex safety-buffering hints: the `x-codex-safety-buffering-*` response
1507
+ * headers and the `safety_buffering` SSE metadata event / field. Absent and
1508
+ * `false` relay everything unchanged.
1509
+ */
1510
+ dropCodexSafetyBuffering?: boolean;
1511
+ }
1512
+
1513
+ /** Resolve the passthrough header policy from the loaded config (absent means "forward everything"). */
1514
+ export function codexSafetyBufferingFilterOptions(
1515
+ config: { dropCodexSafetyBuffering?: boolean },
1516
+ ): CodexSafetyBufferingFilterOptions {
1517
+ return { dropCodexSafetyBuffering: config.dropCodexSafetyBuffering === true };
1518
+ }
1519
+
1520
+ export function sanitizePassthroughHeaders(upstream: Headers, options?: CodexSafetyBufferingFilterOptions): Headers {
1521
+ const dropSafetyBuffering = options?.dropCodexSafetyBuffering === true;
1465
1522
  const DROP = new Set([
1466
1523
  "content-encoding",
1467
1524
  "content-length",
@@ -1478,7 +1535,10 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers {
1478
1535
  ]);
1479
1536
  const out = new Headers();
1480
1537
  upstream.forEach((value, key) => {
1481
- if (!DROP.has(key.toLowerCase())) out.set(key, value);
1538
+ const lower = key.toLowerCase();
1539
+ if (DROP.has(lower)) return;
1540
+ if (dropSafetyBuffering && CODEX_SAFETY_BUFFERING_HEADER_SET.has(lower)) return;
1541
+ out.set(key, value);
1482
1542
  });
1483
1543
  return out;
1484
1544
  }
@@ -1,3 +1,4 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import { gunzipSync, inflateRawSync, inflateSync, zstdDecompressSync } from "node:zlib";
2
3
  import type { TranslatorBudget } from "../lib/translator-budget";
3
4
 
@@ -29,7 +30,7 @@ export const MAX_DECOMPRESSED_BODY_BYTES = 256 * 1024 * 1024;
29
30
  * shrink and is stuck. An UNBOUNDED inbound cap is not an acceptable answer: this admission
30
31
  * limit is the only thing standing between one request and the process heap, and
31
32
  * `readBoundedJsonRequestBody` materializes the body several times over (retained wire bytes,
32
- * decoded bytes, the decoded string, the re-encoded measurement copies, and the parsed object
33
+ * decoded bytes, the decoded string, the serialized measurement string, and the parsed object
33
34
  * graph), so peak RSS is a MULTIPLE of whatever is admitted here. 512 MiB is the largest value
34
35
  * that keeps that multiple survivable on an ordinary machine, and it is what #3573 asked for.
35
36
  */
@@ -324,12 +325,14 @@ export async function readBoundedJsonRequestBody(
324
325
  const decoded = decodeRequestBody(raw, encoding, maxBytes);
325
326
  releaseDecoded = decoded === raw ? undefined : budget?.observeAcceptedRequestCopy(decoded.byteLength);
326
327
  const text = new TextDecoder().decode(decoded);
327
- releaseText = budget?.observeAcceptedRequestCopy(new TextEncoder().encode(text).byteLength);
328
+ // Count UTF-8 without allocating another request-sized byte array for diagnostics.
329
+ releaseText = budget?.observeAcceptedRequestCopy(Buffer.byteLength(text, "utf8"));
328
330
  if (options && "emptyBodyFallback" in options && text.trim() === "") {
329
331
  return options.emptyBodyFallback;
330
332
  }
331
333
  const parsed = JSON.parse(text);
332
- budget?.observeAcceptedRequestCopy(new TextEncoder().encode(JSON.stringify(parsed)).byteLength);
334
+ // Keep the serialized-size contract: normalization can expand numeric literals.
335
+ budget?.observeAcceptedRequestCopy(Buffer.byteLength(JSON.stringify(parsed), "utf8"));
333
336
  return parsed;
334
337
  } finally {
335
338
  releaseText?.();
@@ -3,7 +3,7 @@ import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt";
3
3
  import type { OcxConfig } from "../../types";
4
4
  import { boundedBodyDecodeFailure, readBoundedResponseBody } from "../../lib/bounded-body";
5
5
  import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors";
6
- import { structurallyValidFernetTokens } from "./encrypted-payload";
6
+ import { MAX_AGENT_TASK_CIPHERTEXT_BYTES, MAX_AGENT_TASK_ENCRYPTED_PARTS, structurallyValidFernetTokens } from "./encrypted-payload";
7
7
  import {
8
8
  cachedAgentTaskRecovery,
9
9
  discardCachedAgentTaskRecovery,
@@ -31,7 +31,6 @@ const CODEX_ORIGINATORS = new Set([
31
31
  const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
32
32
  const OPENAI_TOKEN_ISSUERS = new Set(["https://auth.openai.com", "https://auth.openai.com/"]);
33
33
  const OPENAI_TOKEN_AUDIENCE = "https://api.openai.com/v1";
34
- const MAX_CIPHERTEXT_BYTES = 2 * 1024 * 1024;
35
34
  const MAX_ASSIGNMENT_BYTES = 2 * 1024 * 1024;
36
35
  const MAX_RECOVERY_RESPONSE_BYTES = 4 * 1024 * 1024;
37
36
  const CACHE_SCOPE_KEY = randomBytes(32);
@@ -73,12 +72,13 @@ export function agentTaskRecoveryConfig(config: OcxConfig): AgentTaskRecoveryOpt
73
72
 
74
73
  interface AgentEnvelope {
75
74
  itemIndex: number;
76
- encryptedIndex: number;
75
+ encryptedStartIndex: number;
76
+ inputSnapshot: string;
77
77
  headerText: string;
78
78
  messageType: "NEW_TASK" | "MESSAGE";
79
79
  taskName: string;
80
80
  sender: string;
81
- ciphertext: string;
81
+ ciphertexts: readonly string[];
82
82
  author: string;
83
83
  recipient: string;
84
84
  }
@@ -107,10 +107,9 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
107
107
  let messageType: "NEW_TASK" | "MESSAGE" | null = null;
108
108
  let taskName: string | null = null;
109
109
  let sender: string | null = null;
110
- let encryptedIndex = -1;
111
- let ciphertext = "";
112
- let encryptedPartCount = 0;
113
- let ciphertextCount = 0;
110
+ let encryptedStartIndex = -1;
111
+ const ciphertexts: string[] = [];
112
+ let ciphertextBytes = 0;
114
113
 
115
114
  for (let index = 0; index < content.length; index += 1) {
116
115
  const part = content[index] as { type?: unknown; text?: unknown; encrypted_content?: unknown } | null;
@@ -132,13 +131,15 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
132
131
  sender = match[3]!;
133
132
  }
134
133
  }
135
- if (part.type !== "encrypted_content" || typeof part.encrypted_content !== "string") continue;
136
- encryptedPartCount += 1;
137
- for (const token of structurallyValidFernetTokens(part.encrypted_content)) {
138
- ciphertextCount += 1;
139
- encryptedIndex = index;
140
- ciphertext = token;
141
- }
134
+ if (part.type !== "encrypted_content") continue;
135
+ if (typeof part.encrypted_content !== "string") return null;
136
+ ciphertextBytes += Buffer.byteLength(part.encrypted_content);
137
+ if (ciphertexts.length >= MAX_AGENT_TASK_ENCRYPTED_PARTS || ciphertextBytes > MAX_AGENT_TASK_CIPHERTEXT_BYTES) return null;
138
+ const tokens = structurallyValidFernetTokens(part.encrypted_content);
139
+ if (tokens.length !== 1 || tokens[0] !== part.encrypted_content) return null;
140
+ if (encryptedStartIndex < 0) encryptedStartIndex = index;
141
+ if (index !== encryptedStartIndex + ciphertexts.length) return null;
142
+ ciphertexts.push(part.encrypted_content);
142
143
  }
143
144
 
144
145
  if (
@@ -146,11 +147,8 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
146
147
  || !messageType
147
148
  || !taskName
148
149
  || !sender
149
- || encryptedIndex < 0
150
- || encryptedPartCount !== 1
151
- || ciphertextCount !== 1
152
- || (content[encryptedIndex] as { encrypted_content?: unknown }).encrypted_content !== ciphertext
153
- || Buffer.byteLength(ciphertext) > MAX_CIPHERTEXT_BYTES
150
+ || encryptedStartIndex < 0
151
+ || ciphertexts.length === 0
154
152
  ) return null;
155
153
 
156
154
  const itemRecord = item as { author?: unknown; recipient?: unknown };
@@ -159,12 +157,13 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
159
157
 
160
158
  return {
161
159
  itemIndex,
162
- encryptedIndex,
160
+ encryptedStartIndex,
161
+ inputSnapshot: JSON.stringify(item),
163
162
  headerText,
164
163
  messageType,
165
164
  taskName,
166
165
  sender,
167
- ciphertext,
166
+ ciphertexts,
168
167
  author: itemRecord.author,
169
168
  recipient: itemRecord.recipient,
170
169
  };
@@ -197,14 +196,8 @@ function injectAssignment(input: unknown, envelope: AgentEnvelope, assignment: s
197
196
  if (!item || typeof item !== "object") return false;
198
197
  const content = (item as { content?: unknown }).content;
199
198
  if (!Array.isArray(content)) return false;
200
- const part = content[envelope.encryptedIndex] as { type?: unknown; encrypted_content?: unknown } | undefined;
201
- if (
202
- !part
203
- || part.type !== "encrypted_content"
204
- || part.encrypted_content !== envelope.ciphertext
205
- ) return false;
206
-
207
- content[envelope.encryptedIndex] = { type: "input_text", text: assignment };
199
+ if (JSON.stringify(item) !== envelope.inputSnapshot) return false;
200
+ content.splice(envelope.encryptedStartIndex, envelope.ciphertexts.length, { type: "input_text", text: assignment });
208
201
  const message = item as Record<string, unknown>;
209
202
  message.type = "message";
210
203
  message.role = "user";
@@ -313,7 +306,7 @@ function admittedRecovery(
313
306
  .update("\0")
314
307
  .update(envelope.sender)
315
308
  .update("\0")
316
- .update(envelope.ciphertext)
309
+ .update(JSON.stringify(envelope.ciphertexts))
317
310
  .digest("hex");
318
311
  return { admitted: true, recovery: { envelope, admission, cacheKey } };
319
312
  }
@@ -343,7 +336,7 @@ function recoveryPayload(envelope: AgentEnvelope, model: string): string {
343
336
  recipient: envelope.recipient,
344
337
  content: [
345
338
  { type: "input_text", text: envelope.headerText },
346
- { type: "encrypted_content", encrypted_content: envelope.ciphertext },
339
+ ...envelope.ciphertexts.map(encrypted_content => ({ type: "encrypted_content", encrypted_content })),
347
340
  ],
348
341
  }],
349
342
  });
@@ -7,6 +7,7 @@ import {
7
7
  CodexDirectAuthenticationError,
8
8
  CodexMainProfileDrainingError,
9
9
  CodexMainSubstitutionUnavailableError,
10
+ CodexModelAvailabilityError,
10
11
  CodexPoolAuthenticationError,
11
12
  CodexThreadAffinityExpiredError,
12
13
  } from "../../codex/auth-context";
@@ -22,6 +23,13 @@ export interface CodexAuthContextErrorResponseOptions {
22
23
  now: number;
23
24
  }
24
25
 
26
+ export function codexModelAvailabilityErrorResponse(error: CodexModelAvailabilityError): Response {
27
+ if (error.reason === "temporarily_unavailable") {
28
+ return formatErrorResponse(429, "rate_limit_error", error.message);
29
+ }
30
+ return formatErrorResponse(400, "invalid_request_error", error.message);
31
+ }
32
+
25
33
  export function nativeMainRefreshFailureResponse(error: unknown): Response {
26
34
  if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth") {
27
35
  return formatErrorResponse(401, "authentication_error", "Codex main account needs reauthentication");
@@ -86,6 +94,9 @@ export function mapCodexAuthContextErrorToResponse(
86
94
  "Selected Codex account needs reauthentication",
87
95
  );
88
96
  }
97
+ if (error instanceof CodexModelAvailabilityError) {
98
+ return codexModelAvailabilityErrorResponse(error);
99
+ }
89
100
  if (error instanceof CodexPoolAuthenticationError || error instanceof CodexDirectAuthenticationError) {
90
101
  return formatErrorResponse(401, "authentication_error", error.message);
91
102
  }
@@ -6,7 +6,8 @@ import { CodexWsCorrelation } from "./codex-ws-correlation";
6
6
  import type { CodexWsSession } from "./codex-ws-session";
7
7
  import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
8
8
  MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
9
- codexWsFailureDetail, codexWsPreResponseFailure, type CodexWsFailureStage } from "./codex-ws-wire";
9
+ codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
10
+ type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
10
11
 
11
12
  interface ExchangeOptions {
12
13
  session: CodexWsSession;
@@ -16,6 +17,8 @@ interface ExchangeOptions {
16
17
  sseFallback: typeof globalThis.fetch;
17
18
  onQuota?: CodexWsQuotaObserver;
18
19
  beforeDispatch?: (headers: Headers) => void;
20
+ /** Bun version string the caller gated on; stamped onto the stage record. */
21
+ bunVersion?: string;
19
22
  }
20
23
 
21
24
  const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
@@ -83,7 +86,7 @@ function wrappedRejectionResponse(payload: Record<string, unknown>, prelude: Hea
83
86
 
84
87
  /** The sole SSE exchange state machine for both one-shot and retained sockets. */
85
88
  export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
86
- const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch } = options;
89
+ const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch, bunVersion } = options;
87
90
  const { frameText, headers } = prepared;
88
91
  const signal = init.signal ?? undefined;
89
92
  return new Promise<Response>((resolve, reject) => {
@@ -105,6 +108,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
105
108
  let pongs = 0;
106
109
  let sentAt: number | null = null;
107
110
  let firstFrameAt: number | null = null;
111
+ // Numeric close code for the durable stage record; the reason string stays
112
+ // out of it on purpose (#4191 content-free contract).
113
+ let closeCode: number | null = null;
108
114
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
109
115
  const encoder = new TextEncoder();
110
116
  const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
@@ -115,6 +121,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
115
121
  // interval so a peer that answers pings can never trip the silence bound while alive.
116
122
  let silenceTimer: ReturnType<typeof setTimeout> | undefined;
117
123
  let pingTimer: ReturnType<typeof setTimeout> | undefined;
124
+ // The resolved 200, retained so a later body failure can replace its
125
+ // success-shaped stage record with the failure-shaped one (#4191).
126
+ let committedResponse: Response | null = null;
118
127
  const stream = new ReadableStream<Uint8Array>({
119
128
  start(c) { controller = c; },
120
129
  cancel() {
@@ -157,6 +166,28 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
157
166
  pongs,
158
167
  });
159
168
 
169
+ /**
170
+ * The durable twin of failureStage (#4191). `requestBytes` is an explicit
171
+ * parameter so the committed-success path can pass null instead of paying
172
+ * the UTF-8 walk of a megabyte replay frame; failure callers pass
173
+ * `failureStage().requestBytes`, which measures exactly once.
174
+ */
175
+ const stageRecord = (requestBytes: number | null): CodexWsStageRecord => ({
176
+ requestBytes,
177
+ sent,
178
+ upstreamFrames,
179
+ controlFrames,
180
+ relayedEvents,
181
+ firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null,
182
+ elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null,
183
+ pings,
184
+ pongs,
185
+ closeCode,
186
+ reused: session.reused,
187
+ ocxVersion: codexWsOcxVersion(),
188
+ bunVersion: bunVersion ?? "unknown",
189
+ });
190
+
160
191
  const commitResponse = () => {
161
192
  if (responseCommitted) return;
162
193
  responseCommitted = true;
@@ -167,6 +198,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
167
198
  const response = new Response(stream, { status: 200, headers: responseHeaders });
168
199
  metadata?.commit();
169
200
  markCodexWsResponse(response, Boolean(metadata && onQuota));
201
+ markCodexWsStage(response, stageRecord(null));
202
+ committedResponse = response;
170
203
  resolve(response);
171
204
  };
172
205
 
@@ -186,7 +219,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
186
219
  try { controller?.close(); } catch { /* unused stream already closed */ }
187
220
  session.dispose();
188
221
  const message = error instanceof Error ? error.message : String(error);
189
- resolve(codexWsPreResponseFailure(status, message, prelude));
222
+ const failureResponse = codexWsPreResponseFailure(status, message, prelude);
223
+ markCodexWsStage(failureResponse, stageRecord(Buffer.byteLength(frameText, "utf8")));
224
+ resolve(failureResponse);
190
225
  return;
191
226
  }
192
227
  // A response is already flowing (or this transport has no metadata channel and
@@ -196,6 +231,11 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
196
231
  cleanup();
197
232
  try { controller?.error(typeof error === "string" ? new Error(error) : error); } catch { /* stream already done */ }
198
233
  session.dispose();
234
+ // A body failure replaces the success-shaped record commitResponse wrote:
235
+ // this settle is a failure, and the frame size is evidence again.
236
+ if (committedResponse) {
237
+ markCodexWsStage(committedResponse, stageRecord(Buffer.byteLength(frameText, "utf8")));
238
+ }
199
239
  };
200
240
 
201
241
  /** (Re)start the silence bound; every inbound frame or pong is proof of life. */
@@ -408,12 +448,21 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
408
448
  terminal = true;
409
449
  cleanup();
410
450
  try { controller.close(); } catch { /* already closed */ }
451
+ // Refresh the success record with the final counters: the commit-time
452
+ // snapshot predates every relayed event, and the record is more useful
453
+ // when it says what the exchange actually delivered. Still no byte
454
+ // count — the happy path never pays it.
455
+ if (committedResponse) {
456
+ markCodexWsStage(committedResponse, stageRecord(null));
457
+ }
411
458
  session.release(completedId);
412
459
  }
413
460
  };
414
461
 
415
462
  const onClose = (event: unknown) => {
416
463
  cleanup();
464
+ const code = (event as { code?: unknown } | null)?.code;
465
+ if (typeof code === "number") closeCode = code;
417
466
  if (!opened) {
418
467
  if (settledPreOpen) return;
419
468
  settledPreOpen = true;
@@ -4,6 +4,7 @@ import {
4
4
  UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE,
5
5
  UPSTREAM_NO_RESPONSE_CODE,
6
6
  } from "../../lib/upstream-retry";
7
+ import { readFileSync } from "node:fs";
7
8
  // If the 101 never arrives (network black hole), give SSE a chance well before
8
9
  // the caller's connect timeout (default 200s) would fire.
9
10
  export const UPGRADE_DEADLINE_MS = 10_000;
@@ -64,6 +65,60 @@ export function markCodexWsResponse(response: Response, observed: boolean): void
64
65
  if (observed) quotaObservedResponses.add(response);
65
66
  }
66
67
 
68
+ /**
69
+ * The proxy's own version, stamped onto every stage record so a field report
70
+ * can be tied to the exact build that produced it (#4191). Computed locally
71
+ * with the same package.json IIFE management-api.ts / gui-static.ts use —
72
+ * importing management-api from the transport layer would invert the
73
+ * layering and pull the management surface into every WS exchange.
74
+ */
75
+ const OCX_VERSION = (() => {
76
+ try {
77
+ return JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version as string;
78
+ } catch {
79
+ return "0.0.0";
80
+ }
81
+ })();
82
+
83
+ /**
84
+ * The durable form of the stage counters, carried out of the exchange on the
85
+ * resolved Response so the logging layer can persist it without the exchange
86
+ * ever seeing a RequestLogContext (#4191).
87
+ *
88
+ * Everything here is a size, a count, a duration, a boolean, or a semver
89
+ * string: no request body, no header, no account identifier, no conversation
90
+ * text, and no close-reason text can reach a record built by the exchange.
91
+ * `requestBytes` is null on a committed success because the happy path never
92
+ * pays the UTF-8 walk of a megabyte replay frame; it is measured on failure,
93
+ * where its size is the evidence.
94
+ */
95
+ export type CodexWsStageRecord = Omit<CodexWsFailureStage, "requestBytes"> & {
96
+ /** UTF-8 size of the create frame; null on the committed-success record. */
97
+ requestBytes: number | null;
98
+ /** Numeric upstream close code when the socket closed; null otherwise. */
99
+ closeCode: number | null;
100
+ /** True when the exchange ran on a pooled, previously used session. */
101
+ reused: boolean;
102
+ /** OpenCodex version that produced this record. */
103
+ ocxVersion: string;
104
+ /** Bun runtime version the exchange gated on. */
105
+ bunVersion: string;
106
+ };
107
+
108
+ const codexWsStageByResponse = new WeakMap<Response, CodexWsStageRecord>();
109
+
110
+ export function markCodexWsStage(response: Response, record: CodexWsStageRecord): void {
111
+ codexWsStageByResponse.set(response, record);
112
+ }
113
+
114
+ export function readCodexWsStage(response: Response): CodexWsStageRecord | undefined {
115
+ return codexWsStageByResponse.get(response);
116
+ }
117
+
118
+ export function codexWsOcxVersion(): string {
119
+ return OCX_VERSION;
120
+ }
121
+
67
122
  /**
68
123
  * The honest settlement for an exchange that sent its create frame and never saw a
69
124
  * response event.
@@ -1,3 +1,4 @@
1
+ import { capturePoolQuotaWriter } from "../../codex/account-store";
1
2
  import type { Server } from "bun";
2
3
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
3
4
  import {
@@ -356,6 +357,7 @@ async function refreshPoolCompactContext(args: {
356
357
  accessToken: refreshed.accessToken,
357
358
  chatgptAccountId: refreshed.chatgptAccountId,
358
359
  generation: refreshed.generation,
360
+ poolQuotaWriter: capturePoolQuotaWriter(authCtx.accountId, refreshed),
359
361
  };
360
362
  const refreshedProvider = applyCodexAuthContextToProvider(
361
363
  stripCodexRuntimeProviderFields(provider),
@@ -1037,7 +1039,7 @@ export async function handleResponsesCompact(
1037
1039
  upstream.headers,
1038
1040
  authCtx.writerGeneration,
1039
1041
  authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined,
1040
- { modelId: route.modelId },
1042
+ { modelId: route.modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined },
1041
1043
  );
1042
1044
  }
1043
1045
  recordCompactPoolOutcome(authCtx, upstream.status, {
@@ -1078,6 +1080,12 @@ export async function handleResponsesCompact(
1078
1080
  }
1079
1081
  }
1080
1082
  }
1083
+ // Capture the final serving account as well as an earlier rejected account, once per response.
1084
+ if (outcomeCtx.kind === "pool") {
1085
+ const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/quota");
1086
+ applyAccountQuotaFromUpstreamHeaders(outcomeCtx.accountId, upstream.headers, outcomeCtx.writerGeneration,
1087
+ undefined, { modelId: route.modelId, poolWriter: outcomeCtx.poolQuotaWriter });
1088
+ }
1081
1089
  const retryAfter = upstream.headers.get("retry-after");
1082
1090
  const resetAt = [
1083
1091
  upstream.headers.get("x-codex-primary-reset-at"),