@bitkyc08/opencodex 2.31.0 → 2.32.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 (99) hide show
  1. package/bin/ocx.mjs +99 -70
  2. package/gui/dist/assets/{index-DkcRs1fL.js → index-BJwu-ldX.js} +14 -14
  3. package/gui/dist/assets/index-DcBbHIAz.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +6 -8
  7. package/src/adapters/base.ts +2 -0
  8. package/src/adapters/command-code.ts +2 -3
  9. package/src/adapters/cursor/tool-definitions.ts +1 -1
  10. package/src/adapters/google.ts +6 -7
  11. package/src/adapters/kiro.ts +0 -3
  12. package/src/adapters/openai-responses.ts +3 -0
  13. package/src/adapters/tool-catalog-nudge.ts +1 -1
  14. package/src/adapters/xai-web-search.ts +7 -2
  15. package/src/bridge.ts +21 -15
  16. package/src/cli/dispatch.ts +50 -2
  17. package/src/cli/doctor.ts +24 -11
  18. package/src/cli/help.ts +4 -3
  19. package/src/cli/index.ts +11 -4
  20. package/src/cli/models.ts +13 -3
  21. package/src/cli/observe.ts +20 -5
  22. package/src/cli/provider.ts +2 -1
  23. package/src/cli/registry.ts +7 -5
  24. package/src/cli/status.ts +2 -1
  25. package/src/cli/system-restart-client.ts +1 -1
  26. package/src/cli/usage-report.ts +134 -0
  27. package/src/codex/app-server-processes.ts +3 -1
  28. package/src/codex/catalog/parsing.ts +18 -0
  29. package/src/codex/catalog/sync.ts +5 -4
  30. package/src/codex/desktop-app-restart.ts +342 -0
  31. package/src/codex/history-job.ts +32 -3
  32. package/src/codex/history-manifest.ts +112 -0
  33. package/src/codex/history-migration-guardian.ts +5 -5
  34. package/src/codex/history-provider.ts +825 -247
  35. package/src/codex/history-worker.ts +8 -5
  36. package/src/codex/inject.ts +49 -21
  37. package/src/codex/injected-marker.ts +1 -1
  38. package/src/codex/internal/history-writer.ts +4 -3
  39. package/src/codex/native-profile-startup.ts +157 -27
  40. package/src/codex/native-residue.ts +26 -33
  41. package/src/combos/failover.ts +27 -0
  42. package/src/compatibility/index.ts +26 -0
  43. package/src/compatibility/manifest.ts +253 -0
  44. package/src/compatibility/openai-responses.ts +81 -0
  45. package/src/config/atomic-write.ts +219 -0
  46. package/src/config/paths.ts +40 -0
  47. package/src/config/process-state.ts +308 -0
  48. package/src/config/provider-validation.ts +177 -0
  49. package/src/config.ts +75 -812
  50. package/src/generated/compatibility-version.json +135 -79
  51. package/src/images/plan.ts +5 -4
  52. package/src/integrations/ownership-policy.ts +141 -0
  53. package/src/integrations/ownership.ts +10 -0
  54. package/src/integrations/state.ts +44 -5
  55. package/src/integrations/writer.ts +6 -0
  56. package/src/lib/bounded-body.ts +14 -2
  57. package/src/lib/process-control.ts +2 -1
  58. package/src/lib/tool-argument-integers.ts +56 -5
  59. package/src/oauth/health.ts +1 -1
  60. package/src/providers/registry.ts +1 -1
  61. package/src/reasoning-effort.ts +19 -2
  62. package/src/responses/apply-patch-envelope.ts +63 -0
  63. package/src/responses/custom-tool-compat.ts +132 -38
  64. package/src/responses/parser.ts +3 -2
  65. package/src/responses/reasoning-replay-cache.ts +81 -3
  66. package/src/server/auth-cors.ts +9 -7
  67. package/src/server/index.ts +102 -21
  68. package/src/server/local-management-read-client.ts +1 -1
  69. package/src/server/local-provider-reload-client.ts +1 -1
  70. package/src/server/management/agent-settings-routes.ts +1 -1
  71. package/src/server/management/config-routes.ts +4 -1
  72. package/src/server/management/context.ts +1 -1
  73. package/src/server/management/logs-usage-routes.ts +27 -6
  74. package/src/server/management/model-routes.ts +8 -4
  75. package/src/server/management/native-integration-routes.ts +2 -1
  76. package/src/server/management/provider-capability-config.ts +1 -1
  77. package/src/server/management/system-restart.ts +1 -1
  78. package/src/server/port-reclaim.ts +1 -1
  79. package/src/server/proxy-liveness.ts +2 -1
  80. package/src/server/request-log-conversation.ts +30 -0
  81. package/src/server/responses/codex-auth-error.ts +55 -0
  82. package/src/server/responses/combo-stream-preflight.ts +171 -0
  83. package/src/server/responses/compact.ts +6 -21
  84. package/src/server/responses/core.ts +225 -94
  85. package/src/server/responses/fetch-helpers.ts +2 -97
  86. package/src/server/responses-custom-tool-repair.ts +41 -5
  87. package/src/server/responses-undeclared-tool-guard.ts +156 -15
  88. package/src/service.ts +8 -4
  89. package/src/types/request.ts +6 -1
  90. package/src/types/tools.ts +87 -11
  91. package/src/types.ts +1 -1
  92. package/src/update/index.ts +5 -4
  93. package/src/update/job.ts +3 -1
  94. package/src/update/transactional-install.mjs +8 -1
  95. package/src/usage/log.ts +16 -8
  96. package/src/usage/summary.ts +201 -8
  97. package/src/vision/describe.ts +18 -13
  98. package/src/web-search/executor.ts +10 -3
  99. package/gui/dist/assets/index-CH7ncHCC.css +0 -1
@@ -22,8 +22,10 @@ import {
22
22
  durableReplayDestinationIdentity,
23
23
  durableReplayCredentialIdentity,
24
24
  reasoningReplayKeyCredentialIdentity,
25
+ reasoningReplayOpaqueBlobRejectionMemoized,
25
26
  reasoningReplayOAuthCredentialIdentity,
26
27
  reasoningReplayServingIdentityChanged,
28
+ rememberReasoningReplayOpaqueBlobRejection,
27
29
  } from "../../responses/reasoning-replay-cache";
28
30
  import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay";
29
31
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
@@ -113,16 +115,12 @@ import {
113
115
  applyCodexAuthContextToProvider,
114
116
  codexPoolAffinityKey,
115
117
  CodexAccountCooldownError,
116
- codexMainProfileDrainingResponse,
117
- cooldownErrorResponse,
118
118
  CodexAuthContextError,
119
- CodexDirectAuthenticationError,
120
119
  CodexMainProfileDrainingError,
121
120
  CodexPoolAuthenticationError,
122
121
  CodexThreadAffinityExpiredError,
123
122
  headersForCodexAuthContext,
124
123
  materializeCodexUpstreamAuth,
125
- CodexMainSubstitutionUnavailableError,
126
124
  isCodexAuthContextUsable,
127
125
  resolveCodexAuthContext,
128
126
  codexProbeLeaseId,
@@ -225,6 +223,7 @@ import {
225
223
  import {
226
224
  conversationIdFromResponsesRequest,
227
225
  normalizeLogConversationId,
226
+ reasoningReplayConversationIdFromResponsesRequest,
228
227
  sessionIdHeaderFromRequest,
229
228
  } from "../request-log-conversation";
230
229
  import type { AttemptRecoveryKind } from "../../usage/log";
@@ -271,6 +270,7 @@ import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from ".
271
270
  import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
272
271
 
273
272
  import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
273
+ import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
274
274
  import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
275
275
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
276
276
  import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
@@ -306,8 +306,11 @@ import {
306
306
  type RoutedNamespaceToolAliases,
307
307
  } from "../../responses/namespace-tool-compat";
308
308
  import {
309
+ collectDeclaredNamelessClientCallTypes,
309
310
  collectDeclaredWireToolNames,
310
311
  createUndeclaredToolCallGuardBlockRewrite,
312
+ currentTurnWireToolCatalogBody,
313
+ hasExplicitWireToolCatalog,
311
314
  undeclaredToolCallMessage,
312
315
  undeclaredToolCallName,
313
316
  undeclaredToolCallNameInResponse,
@@ -319,6 +322,12 @@ import {
319
322
  emptyCompletionRetryEnabled,
320
323
  guardEmptyCompletionEventStream,
321
324
  } from "./empty-completion-guard";
325
+ import { preflightComboStreamResponse } from "./combo-stream-preflight";
326
+
327
+ // runTurn adapters own an event queue and perform their combo preflight before
328
+ // bridging. A second byte-stream reader would reinterpret that transport's
329
+ // already-committed event boundary and can replay custom adapter work.
330
+ const runTurnAdapterSseResponses = new WeakSet<Response>();
322
331
 
323
332
  /**
324
333
  * Adapters whose continuation state must survive Codex's store:false requests.
@@ -515,6 +524,9 @@ function bindRouteReasoningReplayScope(args: {
515
524
  if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) {
516
525
  parsed._stripReasoningEncryptedContent = true;
517
526
  }
527
+ if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) {
528
+ parsed._stripReasoningEncryptedContent = true;
529
+ }
518
530
  bindProviderContinuationForRoute(parsed, continuationOwner);
519
531
  }
520
532
 
@@ -628,6 +640,26 @@ async function opaqueBlobRejectionBodyForRecovery(
628
640
  }
629
641
  }
630
642
 
643
+ /**
644
+ * Materialize an upstream error body only when the bounded reader observed a complete,
645
+ * display-safe payload. Partial timeout and over-limit prefixes are attacker-controlled,
646
+ * so callers keep their existing status-only fallback instead.
647
+ */
648
+ export async function readDisplaySafeErrorText(
649
+ response: Response,
650
+ signal: AbortSignal,
651
+ fallback: string,
652
+ ): Promise<string> {
653
+ try {
654
+ const body = await readBoundedResponseBody(response, { signal });
655
+ return body.displaySafe ? body.text : fallback;
656
+ } catch {
657
+ // Preserve the former Response.text().catch(fallback) contract. Request-abort
658
+ // classification remains owned by the surrounding response pipeline.
659
+ return fallback;
660
+ }
661
+ }
662
+
631
663
  function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void {
632
664
  parsed._stripReasoningEncryptedContent = true;
633
665
  }
@@ -668,9 +700,20 @@ async function attemptOpaqueBlobRecovery(
668
700
  }
669
701
 
670
702
  args.guard.attempted = true;
703
+ const rejectedScope = args.parsed._reasoningReplayScope
704
+ ? {
705
+ clientThreadId: args.parsed._reasoningReplayScope.clientThreadId,
706
+ ...(args.parsed._reasoningReplayScope.current
707
+ ? { current: { ...args.parsed._reasoningReplayScope.current } }
708
+ : {}),
709
+ }
710
+ : undefined;
671
711
  prepareOpaqueBlobRecovery(args.parsed);
672
712
  try { void args.response.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
673
713
  const result = await rebuild("opaque-blob-rejection");
714
+ if (!("failed" in result) && result.ok) {
715
+ rememberReasoningReplayOpaqueBlobRejection(rejectedScope);
716
+ }
674
717
  return "failed" in result
675
718
  ? { kind: "failed", response: result.failed }
676
719
  : { kind: "recovered", response: result };
@@ -1495,44 +1538,20 @@ async function resolveResponsesCodexAuth(
1495
1538
  substituteMainCredential,
1496
1539
  };
1497
1540
  } catch (err) {
1498
- if (err instanceof CodexAccountCooldownError) {
1499
- return { ok: false, response: cooldownErrorResponse(err, Date.now(), route.codexAccountNamespace) };
1500
- }
1501
- if (err instanceof CodexMainProfileDrainingError) {
1502
- return { ok: false, response: codexMainProfileDrainingResponse() };
1503
- }
1504
- if (err instanceof CodexThreadAffinityExpiredError) {
1505
- return {
1506
- ok: false,
1507
- response: formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"),
1508
- };
1509
- }
1510
1541
  if (err instanceof CodexAuthContextError) {
1511
1542
  const safeAccountLabel = route.codexAccountNamespace
1512
1543
  ? `${route.providerName}-${route.codexAccountNamespace}`
1513
1544
  : formatCodexProviderForLog(route.providerName, err.accountId, config);
1514
1545
  console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
1515
- return {
1516
- ok: false,
1517
- response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
1518
- };
1519
- }
1520
- if (err instanceof CodexPoolAuthenticationError) {
1521
- return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1522
- }
1523
- if (err instanceof CodexDirectAuthenticationError) {
1524
- return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1525
1546
  }
1526
1547
  if (err instanceof ForwardAdmissionCredentialError) {
1527
1548
  return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1528
1549
  }
1529
- if (err instanceof CodexMainSubstitutionUnavailableError) {
1530
- // Fail BEFORE any upstream I/O. The alternative is forwarding the admission secret.
1531
- return {
1532
- ok: false,
1533
- response: formatErrorResponse(401, "authentication_error", "No usable Codex main credential to serve this request"),
1534
- };
1535
- }
1550
+ const response = mapCodexAuthContextErrorToResponse(err, {
1551
+ accountSelector: route.codexAccountNamespace,
1552
+ now: Date.now(),
1553
+ });
1554
+ if (response) return { ok: false, response };
1536
1555
  throw err;
1537
1556
  }
1538
1557
  }
@@ -1900,6 +1919,31 @@ export async function handleComboResponses(
1900
1919
  return clientCancelledResponse();
1901
1920
  }
1902
1921
 
1922
+ if (response.ok && !runTurnAdapterSseResponses.has(response)) {
1923
+ const nativePassthrough = isNativePassthroughSseResponse(response);
1924
+ const eagerRelay = isEagerRelaySseResponse(response);
1925
+ let preflight;
1926
+ try {
1927
+ preflight = await preflightComboStreamResponse(response, childLog);
1928
+ } catch (error) {
1929
+ callbackGate.discard();
1930
+ if (options.abortSignal?.aborted) {
1931
+ retainCancelledAttempt();
1932
+ return clientCancelledResponse();
1933
+ }
1934
+ throw error;
1935
+ }
1936
+ if (preflight.kind === "failed") {
1937
+ callbackGate.discard();
1938
+ terminalRecorder?.("failed", preflight.response.status);
1939
+ response = preflight.response;
1940
+ } else {
1941
+ response = preflight.response;
1942
+ if (nativePassthrough) markNativePassthroughSseResponse(response);
1943
+ if (eagerRelay) markEagerRelaySseResponse(response);
1944
+ }
1945
+ }
1946
+
1903
1947
  if (response.ok) {
1904
1948
  sealRequestAttemptIdentity(
1905
1949
  attempt,
@@ -1955,7 +1999,7 @@ export async function handleComboResponses(
1955
1999
  );
1956
2000
  finishRequestAttempt(
1957
2001
  attempt,
1958
- response.status,
2002
+ failure.response.status,
1959
2003
  Date.now() - started,
1960
2004
  failure.usage,
1961
2005
  );
@@ -1969,7 +2013,7 @@ export async function handleComboResponses(
1969
2013
  return lastFailure;
1970
2014
  }
1971
2015
  console.warn(
1972
- `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`,
2016
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`,
1973
2017
  );
1974
2018
  const nextPick = advanceComboAfterFailure(config, pick, {
1975
2019
  retryAfter: failure.retryAfter,
@@ -2185,7 +2229,6 @@ async function handleResponsesInner(
2185
2229
  if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate;
2186
2230
  if (inboundClientThreadId) {
2187
2231
  parsed._clientThreadId = inboundClientThreadId;
2188
- parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId };
2189
2232
  } else if (
2190
2233
  options.inboundWire === "anthropic"
2191
2234
  && options.promptCacheKeyIsSharedCohort !== true
@@ -2221,15 +2264,31 @@ async function handleResponsesInner(
2221
2264
  ...(force ? { force: true } : {}),
2222
2265
  ...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}),
2223
2266
  });
2224
- // Prefer a pre-populated id (routed Claude) over Responses headers that may be
2225
- // absent or synthetically injected (session_id from prompt_cache_key).
2226
- if (!logCtx.conversationId) {
2227
- logCtx.conversationId = conversationIdFromResponsesRequest({
2267
+ const resolvedConversationId = conversationIdFromResponsesRequest({
2268
+ clientThreadId: parsed._clientThreadId,
2269
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2270
+ threadIdHeader: req.headers.get("thread-id"),
2271
+ cursorConversationId: parsed._cursorConversationId,
2272
+ });
2273
+ // _clientThreadId remains the routing/continuation identity supplied by Codex. Replay state uses
2274
+ // a dedicated raw conversation namespace so mixed headers that carry the same identity still
2275
+ // match, and a shared/synthetic session_id cannot coalesce distinct thread/Cursor conversations.
2276
+ // Keep an Anthropic prompt_cache_key scope already bound above (#1735/#1926).
2277
+ if (!parsed._reasoningReplayScope) {
2278
+ const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({
2228
2279
  clientThreadId: parsed._clientThreadId,
2229
- sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2230
2280
  threadIdHeader: req.headers.get("thread-id"),
2231
2281
  cursorConversationId: parsed._cursorConversationId,
2282
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2232
2283
  });
2284
+ if (reasoningReplayConversationId) {
2285
+ parsed._reasoningReplayScope = { clientThreadId: reasoningReplayConversationId };
2286
+ }
2287
+ }
2288
+ // Prefer a pre-populated id (routed Claude) over Responses headers that may be
2289
+ // absent or synthetically injected (session_id from prompt_cache_key).
2290
+ if (!logCtx.conversationId) {
2291
+ logCtx.conversationId = resolvedConversationId;
2233
2292
  }
2234
2293
  logCtx.requestedModel = parsed.modelId;
2235
2294
  logCtx.requestedEffort = parsed.options.reasoning;
@@ -2848,6 +2907,7 @@ async function handleResponsesInner(
2848
2907
  ? new Map<string, { namespace: string; name: string }>()
2849
2908
  : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
2850
2909
  const routedCustomToolNames = new Set<string>();
2910
+ const routedCustomToolRepairNames = new Set<string>();
2851
2911
  const routedToolSearchNames = new Set<string>();
2852
2912
  // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
2853
2913
  // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
@@ -2870,6 +2930,18 @@ async function handleResponsesInner(
2870
2930
  + `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
2871
2931
  );
2872
2932
  }
2933
+ // Preserve the caller's readable catalog boundary before provider-specific normalization can
2934
+ // remove an unsupported final entry (for example xAI cached-only web search).
2935
+ const replayedInputPrefixLength = parsed._replayPrefixLen ?? 0;
2936
+ const clientToolAuthorizationBody = currentTurnWireToolCatalogBody(
2937
+ parsed._rawBody,
2938
+ replayedInputPrefixLength,
2939
+ );
2940
+ const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody);
2941
+ const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody);
2942
+ const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
2943
+ clientToolAuthorizationBody,
2944
+ );
2873
2945
  let request: Awaited<ReturnType<typeof adapter.buildRequest>>;
2874
2946
  try {
2875
2947
  request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
@@ -2891,6 +2963,12 @@ async function handleResponsesInner(
2891
2963
  || toolBridgeMaps.toolNsMap.get(name)?.freeform === true
2892
2964
  ) routedCustomToolNames.add(name);
2893
2965
  }
2966
+ for (const name of request.routedCustomToolRepairNames ?? []) {
2967
+ if (
2968
+ toolBridgeMaps.freeformToolNames.has(name)
2969
+ || toolBridgeMaps.toolNsMap.get(name)?.freeform === true
2970
+ ) routedCustomToolRepairNames.add(name);
2971
+ }
2894
2972
  }
2895
2973
  for (const name of request.convertedRoutedToolSearchNames ?? []) {
2896
2974
  // The adapter already keeps this set empty when tool_choice forbids the private search.
@@ -2906,13 +2984,13 @@ async function handleResponsesInner(
2906
2984
  // call it cannot execute, and the turn showed a bare `aborted` with the file untouched.
2907
2985
  // Forward auth is the canonical ChatGPT backend speaking Codex's own protocol rather than a
2908
2986
  // routed provider, so it keeps passing through unguarded, as it does for the rewrites above.
2909
- // The guard needs a catalog to compare against, so it stands down when the request carries
2910
- // none. That is not the same claim as "no tools means no tool may be called": a passthrough
2911
- // request can legitimately omit `tools` entirely and still receive a tool call the client
2912
- // understands — `tests/github-copilot-stream-contract.test.ts` sends `{model, input, stream}`
2913
- // with no tools and Copilot answers with a `custom_tool_call` for `apply_patch`. Policing an
2914
- // empty catalog truncates that turn. An unreadable body lands here too, since it yields no
2915
- // names either.
2987
+ // The guard needs a catalog to compare against, so it stands down when the request omits one.
2988
+ // An explicit empty catalog is still authoritative: it declares that no client tools may be
2989
+ // called. A passthrough request can legitimately omit `tools` entirely and still receive a call
2990
+ // the client understands — `tests/github-copilot-stream-contract.test.ts` sends
2991
+ // `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for
2992
+ // `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there
2993
+ // too because the proxy cannot establish the caller's declared authorization boundary.
2916
2994
  const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
2917
2995
  try {
2918
2996
  const body = JSON.parse(bodyText) as unknown;
@@ -2923,16 +3001,46 @@ async function handleResponsesInner(
2923
3001
  return undefined;
2924
3002
  }
2925
3003
  };
2926
- let outboundRequestBody = parseOutboundRequestBody(request.body);
2927
- const declaredWireToolNames = collectDeclaredWireToolNames(outboundRequestBody);
2928
- // Union with the caller's own catalog, because the outbound body is not always a complete
2929
- // record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a
2930
- // request declaring `image_gen.generate` ships `{type:"image_generation"}` upstream and gets
2931
- // the client's name back. Widening only ever makes the guard fire less; a name declared in
2932
- // neither place — #1700's `apply_patch` — is still refused.
2933
- for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name);
2934
- const undeclaredToolGuardActive = declaredWireToolNames.size > 0
2935
- && route.provider.authMode !== "forward";
3004
+ let outboundRequestBody: Record<string, unknown> | undefined;
3005
+ const declaredWireToolNames = new Set<string>();
3006
+ const declaredNamelessClientCallTypes = new Set<string>();
3007
+ let undeclaredToolGuardActive = false;
3008
+ const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => {
3009
+ outboundRequestBody = parseOutboundRequestBody(builtRequest.body);
3010
+ declaredWireToolNames.clear();
3011
+ // With no replay prefix the full outbound body belongs to this turn and its normalized
3012
+ // aliases are authoritative. A continuation's outbound body still contains historical
3013
+ // catalogs (and may promote historical tool-search definitions), so it can never widen the
3014
+ // current caller snapshot captured above.
3015
+ if (replayedInputPrefixLength === 0) {
3016
+ for (const name of collectDeclaredWireToolNames(outboundRequestBody)) {
3017
+ declaredWireToolNames.add(name);
3018
+ }
3019
+ }
3020
+ for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name);
3021
+ declaredNamelessClientCallTypes.clear();
3022
+ if (replayedInputPrefixLength === 0) {
3023
+ for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) {
3024
+ declaredNamelessClientCallTypes.add(callType);
3025
+ }
3026
+ }
3027
+ for (const callType of clientDeclaredNamelessCallTypes) {
3028
+ declaredNamelessClientCallTypes.add(callType);
3029
+ }
3030
+ // On an ordinary request these maps capture caller-catalog identities that normalization may
3031
+ // replace on the outbound wire (for example a client image tool becoming hosted). On replay,
3032
+ // however, the parsed maps also contain historical catalog entries, so only the bounded
3033
+ // current-turn wire snapshot above may authorize a call.
3034
+ if (replayedInputPrefixLength === 0) {
3035
+ for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name);
3036
+ }
3037
+ undeclaredToolGuardActive = (
3038
+ declaredWireToolNames.size > 0
3039
+ || clientDeclaredNamelessCallTypes.size > 0
3040
+ || clientExplicitWireToolCatalog
3041
+ ) && route.provider.authMode !== "forward";
3042
+ };
3043
+ refreshUndeclaredToolGuard(request);
2936
3044
  // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the
2937
3045
  // untouched upstream stream, so it can still observe a `response.completed` the client never
2938
3046
  // received; checking the payload itself rather than a flag shared with the client relay keeps
@@ -2949,7 +3057,11 @@ async function handleResponsesInner(
2949
3057
  // provider) every name looks undeclared, and flipping this would stop recording continuation
2950
3058
  // state for exactly the passthrough traffic the guard deliberately stands down for.
2951
3059
  if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return;
2952
- if (undeclaredToolCallName(payload, declaredWireToolNames) !== undefined) {
3060
+ if (undeclaredToolCallName(
3061
+ payload,
3062
+ declaredWireToolNames,
3063
+ declaredNamelessClientCallTypes,
3064
+ ) !== undefined) {
2953
3065
  inspectionSawUndeclaredTool = true;
2954
3066
  }
2955
3067
  };
@@ -2958,7 +3070,11 @@ async function handleResponsesInner(
2958
3070
  if (inspectionSawUndeclaredTool) return;
2959
3071
  if (
2960
3072
  undeclaredToolGuardActive
2961
- && undeclaredToolCallNameInResponse(response, declaredWireToolNames) !== undefined
3073
+ && undeclaredToolCallNameInResponse(
3074
+ response,
3075
+ declaredWireToolNames,
3076
+ declaredNamelessClientCallTypes,
3077
+ ) !== undefined
2962
3078
  ) {
2963
3079
  return;
2964
3080
  }
@@ -3125,7 +3241,7 @@ async function handleResponsesInner(
3125
3241
  ? request.usageLog.inputTokens
3126
3242
  : undefined;
3127
3243
  if (passthroughEstimate !== undefined) logCtx.usageLogInputTokens = passthroughEstimate;
3128
- outboundRequestBody = parseOutboundRequestBody(request.body);
3244
+ refreshUndeclaredToolGuard(request);
3129
3245
  logCtx.providerAdapter = retryAdapter.name;
3130
3246
  sealRequestAttemptIdentity(
3131
3247
  logCtx.activeAttempt,
@@ -3235,6 +3351,7 @@ async function handleResponsesInner(
3235
3351
  const msg = err instanceof Error ? err.message : String(err);
3236
3352
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
3237
3353
  }
3354
+ refreshUndeclaredToolGuard(request);
3238
3355
  try {
3239
3356
  upstreamResponse = await fetchWithTransientRetry(
3240
3357
  recovery => {
@@ -3386,6 +3503,7 @@ async function handleResponsesInner(
3386
3503
  authCtx = retry.authCtx;
3387
3504
  request = retry.request;
3388
3505
  refreshRoutedNamespaceToolAliases(request);
3506
+ refreshUndeclaredToolGuard(request);
3389
3507
  upstreamResponse = retry.upstreamResponse;
3390
3508
  selectedForwardHeaders = retry.selectedForwardHeaders;
3391
3509
  // Keep subagent quota-failure health keyed to the account that actually served.
@@ -3424,13 +3542,21 @@ async function handleResponsesInner(
3424
3542
  const passthroughCt = headers.get("content-type")?.toLowerCase();
3425
3543
  const isEventStream = passthroughCt?.includes("text/event-stream")
3426
3544
  || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
3427
- const terminalRecorder = codexForwardTerminalOutcomeRecorder(
3545
+ const recordTerminalOutcome = codexForwardTerminalOutcomeRecorder(
3428
3546
  config,
3429
3547
  authCtx,
3430
3548
  route.provider,
3431
3549
  route.modelId,
3432
3550
  logCtx,
3433
3551
  );
3552
+ let terminalOutcomeRecorded = false;
3553
+ const terminalRecorder = recordTerminalOutcome
3554
+ ? (status: ResponsesTerminalStatus, httpStatusOverride?: number): void => {
3555
+ if (terminalOutcomeRecorded) return;
3556
+ terminalOutcomeRecorded = true;
3557
+ recordTerminalOutcome(status, httpStatusOverride);
3558
+ }
3559
+ : undefined;
3434
3560
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
3435
3561
  // Capture quota from upstream response for multi-account tracking
3436
3562
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
@@ -3507,19 +3633,13 @@ async function handleResponsesInner(
3507
3633
  options.onConsumedComboFailure?.(failure);
3508
3634
  return failure.response;
3509
3635
  }
3510
- // The plain passthrough error path has no bounded reader of its own: `.text()` attaches
3511
- // the reader only when it runs, so an abort landing between fetch resolution and that
3512
- // call orphans Bun's internal read rejection (src/lib/abort.ts).
3513
- const detachPassthroughErrorGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
3514
- try {
3515
- const errorText = await upstreamResponse.text().catch(() => "");
3516
- return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
3517
- statusText: upstreamResponse.statusText,
3518
- headers,
3519
- });
3520
- } finally {
3521
- detachPassthroughErrorGuard();
3522
- }
3636
+ // The bounded reader owns the original body, deadline, abort settlement, and lock.
3637
+ // Unsafe partial data falls back to #452's non-empty status-only JSON.
3638
+ const errorText = await readDisplaySafeErrorText(upstreamResponse, upstream.signal, "");
3639
+ return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
3640
+ statusText: upstreamResponse.statusText,
3641
+ headers,
3642
+ });
3523
3643
  }
3524
3644
 
3525
3645
  // Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
@@ -3582,8 +3702,12 @@ async function handleResponsesInner(
3582
3702
  payloadRewrites.length > 0
3583
3703
  ? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites))
3584
3704
  : undefined,
3585
- routedCustomToolNames.size > 0
3586
- ? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget)
3705
+ routedCustomToolNames.size > 0 || routedCustomToolRepairNames.size > 0
3706
+ ? createRoutedCustomToolRestoreBlockRewrite(
3707
+ routedCustomToolNames,
3708
+ translatorBudget,
3709
+ routedCustomToolRepairNames,
3710
+ )
3587
3711
  : undefined,
3588
3712
  routedToolSearchNames.size > 0
3589
3713
  ? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget)
@@ -3598,7 +3722,10 @@ async function handleResponsesInner(
3598
3722
  // Last: every rewrite above can still rename or reshape a call item, so the guard must
3599
3723
  // compare the names the client will actually receive against the declared catalog.
3600
3724
  undeclaredToolGuardActive
3601
- ? createUndeclaredToolCallGuardBlockRewrite(declaredWireToolNames)
3725
+ ? createUndeclaredToolCallGuardBlockRewrite(
3726
+ declaredWireToolNames,
3727
+ declaredNamelessClientCallTypes,
3728
+ )
3602
3729
  : undefined,
3603
3730
  ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
3604
3731
  const clientBlockRewrite = blockRewrites.length > 0
@@ -3788,6 +3915,7 @@ async function handleResponsesInner(
3788
3915
  const restored = restoreRoutedCustomCallsInJson(
3789
3916
  restoredNamespace,
3790
3917
  routedCustomToolNames,
3918
+ routedCustomToolRepairNames,
3791
3919
  );
3792
3920
  const restoredToolSearch = restoreRoutedToolSearchCallsInJson(
3793
3921
  restored,
@@ -3814,7 +3942,11 @@ async function handleResponsesInner(
3814
3942
  if (undeclaredToolGuardActive) {
3815
3943
  const undeclared = (() => {
3816
3944
  try {
3817
- return undeclaredToolCallNameInResponse(JSON.parse(clientJson), declaredWireToolNames);
3945
+ return undeclaredToolCallNameInResponse(
3946
+ JSON.parse(clientJson),
3947
+ declaredWireToolNames,
3948
+ declaredNamelessClientCallTypes,
3949
+ );
3818
3950
  } catch {
3819
3951
  return undefined;
3820
3952
  }
@@ -4318,9 +4450,11 @@ async function handleResponsesInner(
4318
4450
  );
4319
4451
  const bridgeTurnAc = new AbortController();
4320
4452
  const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, undefined, options.turnAdmissionLease);
4321
- return new Response(trackedSse, {
4453
+ const response = new Response(trackedSse, {
4322
4454
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
4323
4455
  });
4456
+ runTurnAdapterSseResponses.add(response);
4457
+ return response;
4324
4458
  }
4325
4459
 
4326
4460
  await runTurn();
@@ -4783,14 +4917,16 @@ async function handleResponsesInner(
4783
4917
  options.onConsumedComboFailure?.(failure);
4784
4918
  return failure.response;
4785
4919
  }
4786
- // The plain error path has no bounded reader of its own: `.text()` attaches the reader
4787
- // only when it runs, so an abort landing between fetch resolution and that call orphans
4788
- // Bun's internal read rejection — the uncatchable teardown `cancelBodyOnAbort` absorbs
4789
- // (src/lib/abort.ts). Found while investigating #1419; not a fix for the native trap.
4790
- const detachErrorBodyGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
4791
- const errorText = await upstreamResponse.text().catch(() => "unknown error");
4792
- detachErrorBodyGuard();
4793
- cleanupUpstreamAbort();
4920
+ let errorText: string;
4921
+ try {
4922
+ errorText = await readDisplaySafeErrorText(
4923
+ upstreamResponse,
4924
+ upstream.signal,
4925
+ "unknown error",
4926
+ );
4927
+ } finally {
4928
+ cleanupUpstreamAbort();
4929
+ }
4794
4930
  if (!isFixedCodexAccount(authCtx)) {
4795
4931
  recordSubagentQuotaFailureForThreadSpawn(
4796
4932
  req.headers,
@@ -5081,12 +5217,7 @@ async function handleResponsesInner(
5081
5217
  }
5082
5218
 
5083
5219
  if (!response.ok) {
5084
- // Same pre-read guard as the initial response's error branch: a non-2xx continuation body
5085
- // is still a Bun fetch body, and an abort landing before `.text()` attaches its reader
5086
- // orphans the internal rejection.
5087
- const detachContinuationErrorGuard = cancelBodyOnAbort(response.body, upstream.signal);
5088
- const errorText = await response.text().catch(() => "unknown error");
5089
- detachContinuationErrorGuard();
5220
+ const errorText = await readDisplaySafeErrorText(response, upstream.signal, "unknown error");
5090
5221
  yield {
5091
5222
  type: "error",
5092
5223
  status: response.status,