@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -101,3 +101,55 @@ export function slugEquivalenceKey(slug: string): string {
101
101
  export function slugsEquivalent(a: string, b: string): boolean {
102
102
  return a === b || slugEquivalenceKey(a) === slugEquivalenceKey(b);
103
103
  }
104
+
105
+ /**
106
+ * Resolve one config selection against a provider's known native ids (#2491).
107
+ *
108
+ * `slugEquivalenceKey` is deliberately lossy — the Codex one-slash rule forces `a/b` and
109
+ * `a-b` onto the same encoded form — so a selection written in either spelling matches BOTH
110
+ * when a provider publishes both. Filtering and persisted sync share that key, which keeps
111
+ * them consistent with each other but silently over-grants.
112
+ *
113
+ * This resolver keeps the tolerant behaviour (a selection still matches through either
114
+ * spelling, and an id absent from an incomplete live roster still resolves) while reporting
115
+ * whether the match was EXACT or merely equivalent. A caller that can afford to be strict —
116
+ * one holding a complete known-id set — can then prefer the exact row instead of granting the
117
+ * whole collision class.
118
+ *
119
+ * Returning the ambiguity rather than resolving it is deliberate: the roster is an incomplete
120
+ * dictionary, so silently narrowing to the exact spelling would hide a published id whenever
121
+ * discovery omitted it. The caller owns that tradeoff because only the caller knows whether
122
+ * its id set is complete.
123
+ */
124
+ export interface SlugSelectionMatch {
125
+ /** Native ids this selection admits. */
126
+ readonly matched: readonly string[];
127
+ /** The id whose raw form the selection names exactly, when one exists. */
128
+ readonly exact: string | undefined;
129
+ /** True when more than one known id shares the selection's encoded form. */
130
+ readonly ambiguous: boolean;
131
+ }
132
+
133
+ export function resolveSlugSelection(
134
+ provider: string,
135
+ selection: string,
136
+ knownIds: Iterable<string>,
137
+ ): SlugSelectionMatch {
138
+ // A slash in the selection is ambiguous on its own: `p/a-b` is provider-qualified, while
139
+ // `a/b` is a bare NATIVE id that happens to contain a slash. Treating every slash-bearing
140
+ // selection as provider-qualified made `a/b` resolve against provider "a", so the same
141
+ // collision reported ambiguous through the dash spelling and unambiguous through the slash
142
+ // spelling — the exact asymmetry this resolver exists to remove.
143
+ const qualified = selection.startsWith(`${provider}/`)
144
+ ? selection
145
+ : routedSlug(provider, selection);
146
+ const selectionKey = slugEquivalenceKey(qualified);
147
+ const matched: string[] = [];
148
+ let exact: string | undefined;
149
+ for (const id of knownIds) {
150
+ if (slugEquivalenceKey(routedSlug(provider, id)) !== selectionKey) continue;
151
+ matched.push(id);
152
+ if (id === selection || `${provider}/${id}` === selection) exact = id;
153
+ }
154
+ return { matched, exact, ambiguous: matched.length > 1 };
155
+ }
@@ -4,6 +4,27 @@ import { resolveGithubCopilotTransport } from "./github-copilot-transport";
4
4
 
5
5
  export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
6
6
 
7
+ /** The two hosts that serve xAI's Responses API: the public API and the Grok CLI proxy. */
8
+ const XAI_RESPONSES_HOSTS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
9
+
10
+ /**
11
+ * True when this provider's Responses traffic terminates at xAI itself.
12
+ *
13
+ * Probed 2026-08-22 one field per request: the two hosts accept and refuse exactly the same
14
+ * web_search fields, so they are one dialect rather than two. Matching is exact-host over
15
+ * https, which keeps lookalikes (`api.x.ai.evil.test`) and nonstandard ports out.
16
+ */
17
+ export function isXaiResponsesDestination(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
18
+ try {
19
+ const url = new URL(provider.baseUrl);
20
+ return url.protocol === "https:"
21
+ && XAI_RESPONSES_HOSTS.has(url.hostname.toLowerCase())
22
+ && (url.port === "" || url.port === "443");
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
7
28
  export const XAI_GROK_COMPATIBILITY = {
8
29
  version: "0.2.93",
9
30
  userAgent: "opencodex-grok/0.2.93",
@@ -14,6 +14,16 @@ export const CODEX_REASONING_LEVELS: { effort: string; description: string }[] =
14
14
  const CODEX_REASONING_ORDER = CODEX_REASONING_LEVELS.map(l => l.effort);
15
15
  const CODEX_REASONING_SET = new Set(CODEX_REASONING_ORDER);
16
16
 
17
+ /**
18
+ * Sentinel wire value in reasoningEffortMap / modelReasoningEffortMap to explicitly
19
+ * omit the reasoning_effort field from the upstream wire request (issue #2356).
20
+ */
21
+ export const REASONING_EFFORT_OMIT_SENTINEL = "__omit__";
22
+
23
+ export function isReasoningEffortOmitted(wireEffort: string | undefined): boolean {
24
+ return wireEffort === REASONING_EFFORT_OMIT_SENTINEL;
25
+ }
26
+
17
27
  /** True when `effort` is a member of the Codex reasoning ladder (low..ultra). */
18
28
  export function isCodexReasoningEffort(effort: string): boolean {
19
29
  return CODEX_REASONING_SET.has(effort);
@@ -170,7 +180,10 @@ export function mapReasoningEffort(provider: OcxProviderConfig, modelId: string,
170
180
  const boundary = requested === "ultra" ? "max" : requested;
171
181
 
172
182
  const wireMap = reasoningEffortMapFor(provider, modelId);
173
- if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, boundary)) return wireMap[boundary];
183
+ if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, boundary)) {
184
+ const mapped = wireMap[boundary];
185
+ return mapped === REASONING_EFFORT_OMIT_SENTINEL ? undefined : mapped;
186
+ }
174
187
 
175
188
  const supported = configuredReasoningEfforts(provider, modelId);
176
189
  const codexEffort = supported !== undefined ? clampToSupportedCodexEffort(boundary, supported) : requestToCodexEffort(boundary);
@@ -178,6 +191,10 @@ export function mapReasoningEffort(provider: OcxProviderConfig, modelId: string,
178
191
 
179
192
  // Belt for the odd config where the supported ladder is ultra-only and the clamp lands on it.
180
193
  const wire = codexEffort === "ultra" ? "max" : codexEffort;
181
- if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, wire)) return wireMap[wire];
194
+ if (wireMap && Object.prototype.hasOwnProperty.call(wireMap, wire)) {
195
+ const mapped = wireMap[wire];
196
+ return mapped === REASONING_EFFORT_OMIT_SENTINEL ? undefined : mapped;
197
+ }
198
+ if (wire === REASONING_EFFORT_OMIT_SENTINEL) return undefined;
182
199
  return wire;
183
200
  }
@@ -0,0 +1,63 @@
1
+ // Representation repair for top-level Codex apply_patch custom-tool payloads.
2
+ //
3
+ // Some routed models decorate the first and last lines as
4
+ // `*** Begin Patch ***` / `*** End Patch ***`. Codex rejects those otherwise
5
+ // valid custom-tool payloads. Repair is deliberately limited to a complete,
6
+ // structurally recognizable top-level patch: arbitrary `exec` JavaScript is
7
+ // caller-authored executable input and must remain byte-identical.
8
+ //
9
+ // This is the same intent boundary as `src/lib/tool-argument-integers.ts`:
10
+ // repair the one faithful reading, leave genuine patch content alone.
11
+
12
+ const PATCH_BEGIN = "*** Begin Patch";
13
+ const PATCH_END = "*** End Patch";
14
+ const TOP_LEVEL_PATCH_ENVELOPE = /^(\*\*\* Begin Patch(?: \*\*\*)?)(\r?\n)([\s\S]*)(\r?\n)(\*\*\* End Patch(?: \*\*\*)?)(\r?\n)?$/;
15
+ const PATCH_OPERATION_LINE = /^\*\*\* (?:Add|Update|Delete) File: .+$/m;
16
+
17
+ /** Unwrap the `{input:string}` function-call wrapper used for freeform tools. */
18
+ export function unwrapFreeformToolInput(argumentsText: unknown): string {
19
+ if (typeof argumentsText !== "string") return "";
20
+ try {
21
+ const parsed: unknown = JSON.parse(argumentsText);
22
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
23
+ const input = (parsed as { input?: unknown }).input;
24
+ if (typeof input === "string") return input;
25
+ }
26
+ } catch {
27
+ // The string is the freeform body, not nested JSON.
28
+ }
29
+ return argumentsText;
30
+ }
31
+
32
+ /**
33
+ * Strip trailing `***` only from the outer lines of one complete patch.
34
+ * Internal patch content, incomplete envelopes, and non-patch text are exact
35
+ * pass-throughs.
36
+ */
37
+ export function normalizeApplyPatchDelimiters(text: string): string {
38
+ const match = TOP_LEVEL_PATCH_ENVELOPE.exec(text);
39
+ if (!match) return text;
40
+ const [, begin, beginBreak, body, endBreak, end, trailingBreak = ""] = match;
41
+ if (!PATCH_OPERATION_LINE.test(body)) return text;
42
+ if (begin === PATCH_BEGIN && end === PATCH_END) return text;
43
+ return `${PATCH_BEGIN}${beginBreak}${body}${endBreak}${PATCH_END}${trailingBreak}`;
44
+ }
45
+
46
+ /**
47
+ * Repair freeform input before Codex sees it.
48
+ *
49
+ * Only a bare or reserved-`functions` `apply_patch` payload may receive delimiter
50
+ * repair. Remote namespaces own their grammar; those bodies and every other
51
+ * freeform input are unwrapped and left byte-exact.
52
+ */
53
+ export function repairFreeformToolInput(
54
+ argumentsText: unknown,
55
+ toolName = "",
56
+ namespace?: string,
57
+ ): string {
58
+ const unwrapped = unwrapFreeformToolInput(argumentsText);
59
+ const ownsApplyPatchGrammar = namespace === undefined || namespace === "functions";
60
+ return ownsApplyPatchGrammar && toolName === "apply_patch"
61
+ ? normalizeApplyPatchDelimiters(unwrapped)
62
+ : unwrapped;
63
+ }
@@ -0,0 +1,50 @@
1
+ import { normalizeApplyPatchDelimiters } from "./apply-patch-envelope";
2
+
3
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
4
+ return !!value && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+
7
+ function unwrapPatchInput(value: string): string {
8
+ try {
9
+ const parsed: unknown = JSON.parse(value);
10
+ if (isPlainObject(parsed)) {
11
+ if (typeof parsed.input === "string") return parsed.input;
12
+ if (typeof parsed.patch === "string") return parsed.patch;
13
+ }
14
+ } catch {
15
+ // Native custom calls carry the patch body directly.
16
+ }
17
+ return value;
18
+ }
19
+
20
+ /**
21
+ * Convert a nested Code Mode helper call into unified-exec JavaScript.
22
+ *
23
+ * Parsed values are serialized as data, never interpolated as source, so command and patch text
24
+ * cannot escape the generated call. Invalid structured shell payloads are also passed as data so
25
+ * nested-tool validation can reject them without evaluating provider text as JavaScript.
26
+ */
27
+ export function compileCodeModeHelperInput(argumentsText: unknown, toolName: string): string {
28
+ if (typeof argumentsText !== "string") return "";
29
+ if (toolName === "apply_patch") {
30
+ const patch = normalizeApplyPatchDelimiters(unwrapPatchInput(argumentsText));
31
+ return `const result = await tools.apply_patch(${JSON.stringify(patch)});\ntext(result);`;
32
+ }
33
+ let parsed: unknown = argumentsText;
34
+ try {
35
+ parsed = JSON.parse(argumentsText);
36
+ } catch {
37
+ // Keep malformed provider text as data rather than executable source.
38
+ }
39
+ const args: unknown = isPlainObject(parsed) ? { ...parsed } : parsed;
40
+ if (
41
+ toolName === "shell_command"
42
+ && isPlainObject(args)
43
+ && typeof args.command === "string"
44
+ && args.cmd === undefined
45
+ ) {
46
+ args.cmd = args.command;
47
+ delete args.command;
48
+ }
49
+ return `const result = await tools.exec_command(${JSON.stringify(args)});\ntext(result);`;
50
+ }
@@ -1,4 +1,10 @@
1
- import { namespacedToolName } from "../types";
1
+ import { namespacedToolName, normalizeDeclaredToolName } from "../types";
2
+ import {
3
+ normalizeApplyPatchDelimiters,
4
+ repairFreeformToolInput,
5
+ unwrapFreeformToolInput,
6
+ } from "./apply-patch-envelope";
7
+ import { compileCodeModeHelperInput } from "./code-mode-helper-compat";
2
8
  import { collectResponsesToolGroups } from "./tool-groups";
3
9
 
4
10
  const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]);
@@ -15,19 +21,42 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
21
  return !!value && typeof value === "object" && !Array.isArray(value);
16
22
  }
17
23
 
18
- function customToolInput(argumentsText: unknown): string {
19
- if (typeof argumentsText !== "string") return "";
20
- try {
21
- const parsed = JSON.parse(argumentsText) as unknown;
22
- if (isPlainObject(parsed) && typeof parsed.input === "string") return parsed.input;
23
- } catch { /* malformed arguments stay visible to the client */ }
24
- return argumentsText;
25
- }
26
-
27
24
  function customToolWireName(namespace: string | undefined, name: string): string {
28
25
  return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name);
29
26
  }
30
27
 
28
+ function toolChoiceAllowsRoutedCustomTool(
29
+ body: unknown,
30
+ wireName: string,
31
+ candidateNames: ReadonlySet<string>,
32
+ ): boolean {
33
+ if (!isPlainObject(body)) return true;
34
+ const choice = body.tool_choice;
35
+ if (choice === undefined || choice === null || choice === "auto" || choice === "required") {
36
+ return true;
37
+ }
38
+ if (choice === "none") return false;
39
+ if (!isPlainObject(choice)) return true;
40
+
41
+ const selectorAllows = (selector: unknown): boolean => {
42
+ if (!isPlainObject(selector) || typeof selector.name !== "string") return false;
43
+ if (selector.type !== "custom") return false;
44
+ if (typeof selector.namespace === "string") {
45
+ return customToolWireName(selector.namespace, selector.name) === wireName;
46
+ }
47
+ if (selector.name === wireName) return true;
48
+ const suffix = `__${selector.name}`;
49
+ const candidates = [...candidateNames].filter(name => name.endsWith(suffix));
50
+ return candidates.length === 1 && candidates[0] === wireName;
51
+ };
52
+
53
+ if (choice.type === "function" || choice.type === "custom") return selectorAllows(choice);
54
+ if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
55
+ return choice.tools.some(selectorAllows);
56
+ }
57
+ return false;
58
+ }
59
+
31
60
  /** Final upstream identity of a call, including a namespace restored by an earlier rewrite. */
32
61
  export function routedCustomToolWireName(value: unknown): string | undefined {
33
62
  if (!isPlainObject(value) || typeof value.name !== "string") return undefined;
@@ -37,13 +66,28 @@ export function routedCustomToolWireName(value: unknown): string | undefined {
37
66
  );
38
67
  }
39
68
 
69
+ /** Resolve a provider-emitted wire name to the routed custom tool the client declared. */
70
+ export function routedCustomToolTargetName(
71
+ value: unknown,
72
+ names: ReadonlySet<string>,
73
+ declaredNames?: ReadonlySet<string>,
74
+ ): string | undefined {
75
+ const wireName = routedCustomToolWireName(value);
76
+ if (wireName === undefined) return undefined;
77
+ if (names.has(wireName)) return wireName;
78
+ if (!isPlainObject(value) || typeof value.namespace === "string") return undefined;
79
+ const normalized = normalizeDeclaredToolName(wireName, declaredNames);
80
+ return normalized !== wireName && names.has(normalized) ? normalized : undefined;
81
+ }
82
+
40
83
  /**
41
- * Names of converted custom declarations after namespace lowering. Restoration uses these exact
42
- * wire identities so same-named function and custom children in different namespaces stay distinct.
84
+ * Names of custom declarations after namespace lowering. The selection flag separates converted
85
+ * names from native passthrough names while keeping same-named function and custom children distinct.
43
86
  */
44
87
  function collectRoutedCustomToolWireNames(
45
88
  body: unknown,
46
89
  supportsResponsesCustomTools?: boolean,
90
+ passthrough = false,
47
91
  ): Set<string> {
48
92
  const names = new Set<string>();
49
93
  const groups = collectResponsesToolGroups(body);
@@ -64,7 +108,7 @@ function collectRoutedCustomToolWireNames(
64
108
  if (
65
109
  tool.type === "custom"
66
110
  && typeof tool.name === "string"
67
- && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools)
111
+ && routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) === passthrough
68
112
  ) {
69
113
  names.add(tool.name);
70
114
  continue;
@@ -77,7 +121,8 @@ function collectRoutedCustomToolWireNames(
77
121
  isPlainObject(child)
78
122
  && child.type === "custom"
79
123
  && typeof child.name === "string"
80
- && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools)
124
+ && routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) === passthrough
125
+ && (!passthrough || tool.name === BUILTIN_FUNCTIONS_NAMESPACE)
81
126
  && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name))
82
127
  ) names.add(customToolWireName(tool.name, child.name));
83
128
  }
@@ -85,7 +130,6 @@ function collectRoutedCustomToolWireNames(
85
130
  }
86
131
  return names;
87
132
  }
88
-
89
133
  export function customToolItemId(id: unknown): unknown {
90
134
  if (typeof id !== "string") return id;
91
135
  return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id;
@@ -203,64 +247,138 @@ export function rewriteRoutedCustomToolsForUpstream(
203
247
  ): {
204
248
  body: unknown;
205
249
  names: Set<string>;
250
+ repairNames: Set<string>;
206
251
  } {
207
252
  const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools);
208
253
  const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools);
209
- if (conversionNames.size === 0) return { body, names };
254
+ const repairNames = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools, true);
255
+ for (const name of repairNames) {
256
+ if (!toolChoiceAllowsRoutedCustomTool(body, name, repairNames)) repairNames.delete(name);
257
+ }
258
+ if (conversionNames.size === 0) return { body, names, repairNames };
210
259
  const callIds = new Set<string>();
211
260
  collectConvertedCallIds(body, conversionNames, callIds);
212
- return { body: rewriteForUpstream(body, conversionNames, callIds), names };
261
+ return { body: rewriteForUpstream(body, conversionNames, callIds), names, repairNames };
213
262
  }
214
263
 
215
264
  export function restoreRoutedCustomCalls(
216
265
  value: unknown,
217
266
  names: ReadonlySet<string>,
267
+ repairNames: ReadonlySet<string> = new Set(),
268
+ declaredNames?: ReadonlySet<string>,
218
269
  ): { value: unknown; changed: boolean } {
219
- if (Array.isArray(value)) {
270
+ if (!isPlainObject(value)) return { value, changed: false };
271
+
272
+ const restoreItem = (item: unknown): { value: unknown; changed: boolean } => {
273
+ if (!isPlainObject(item)) return { value: item, changed: false };
274
+ const wireName = routedCustomToolWireName(item);
275
+ const targetName = routedCustomToolTargetName(item, names, declaredNames);
276
+ if (
277
+ (item.type === "function_call" || item.type === "custom_tool_call")
278
+ && typeof item.name === "string"
279
+ && wireName !== undefined
280
+ && targetName !== undefined
281
+ ) {
282
+ const sourceInput = item.type === "function_call" ? item.arguments : item.input;
283
+ const aliased = targetName !== wireName;
284
+ const restored: Record<string, unknown> = {
285
+ ...item,
286
+ type: "custom_tool_call",
287
+ id: customToolItemId(item.id),
288
+ name: aliased ? targetName : item.name,
289
+ input: aliased && sourceInput !== ""
290
+ ? compileCodeModeHelperInput(sourceInput, item.name)
291
+ : repairFreeformToolInput(
292
+ sourceInput,
293
+ targetName,
294
+ typeof item.namespace === "string" ? item.namespace : undefined,
295
+ ),
296
+ };
297
+ delete restored.arguments;
298
+ if (aliased) delete restored.namespace;
299
+ return { value: restored, changed: true };
300
+ }
301
+ if (
302
+ item.type === "custom_tool_call"
303
+ && typeof item.name === "string"
304
+ && wireName !== undefined
305
+ && repairNames.has(wireName)
306
+ && typeof item.input === "string"
307
+ ) {
308
+ const input = normalizeApplyPatchDelimiters(item.input);
309
+ if (input !== item.input) return { value: { ...item, input }, changed: true };
310
+ }
311
+ return { value: item, changed: false };
312
+ };
313
+
314
+ const restoreOutput = (output: unknown): { value: unknown; changed: boolean } => {
315
+ if (!Array.isArray(output)) return { value: output, changed: false };
220
316
  let changed = false;
221
- const restored = value.map(entry => {
222
- const result = restoreRoutedCustomCalls(entry, names);
317
+ const restored = output.map(item => {
318
+ const result = restoreItem(item);
223
319
  changed ||= result.changed;
224
320
  return result.value;
225
321
  });
226
- return changed ? { value: restored, changed: true } : { value, changed: false };
227
- }
228
- if (!isPlainObject(value)) return { value, changed: false };
322
+ return changed ? { value: restored, changed: true } : { value: output, changed: false };
323
+ };
229
324
 
230
325
  let changed = false;
231
- const restored: Record<string, unknown> = {};
232
- for (const [key, entry] of Object.entries(value)) {
233
- const result = restoreRoutedCustomCalls(entry, names);
234
- restored[key] = result.value;
235
- changed ||= result.changed;
326
+ const restored: Record<string, unknown> = { ...value };
327
+ const output = restoreOutput(value.output);
328
+ if (output.changed) {
329
+ restored.output = output.value;
330
+ changed = true;
236
331
  }
237
332
 
238
- const wireName = routedCustomToolWireName(value);
239
- if (value.type === "function_call" && wireName !== undefined && names.has(wireName)) {
240
- restored.type = "custom_tool_call";
241
- restored.id = customToolItemId(value.id);
242
- restored.input = customToolInput(value.arguments);
243
- delete restored.arguments;
244
- changed = true;
333
+ if (
334
+ (value.type === "response.output_item.added" || value.type === "response.output_item.done")
335
+ && isPlainObject(value.item)
336
+ ) {
337
+ const item = restoreItem(value.item);
338
+ if (item.changed) {
339
+ restored.item = item.value;
340
+ changed = true;
341
+ }
342
+ }
343
+
344
+ if (
345
+ typeof value.type === "string"
346
+ && value.type.startsWith("response.")
347
+ && isPlainObject(value.response)
348
+ ) {
349
+ const response = restoreRoutedCustomCalls(value.response, names, repairNames, declaredNames);
350
+ if (response.changed) {
351
+ restored.response = response.value;
352
+ changed = true;
353
+ }
245
354
  }
355
+
246
356
  return changed ? { value: restored, changed: true } : { value, changed: false };
247
357
  }
248
358
 
249
359
  export function restoreRoutedCustomCallsInJson(
250
360
  text: string,
251
361
  names: ReadonlySet<string>,
362
+ repairNames: ReadonlySet<string> = new Set(),
363
+ declaredNames?: ReadonlySet<string>,
252
364
  ): string {
253
- if (names.size === 0) return text;
365
+ if (names.size === 0 && repairNames.size === 0) return text;
254
366
  let payload: unknown;
255
367
  try {
256
368
  payload = JSON.parse(text);
257
369
  } catch {
258
370
  return text;
259
371
  }
260
- const restored = restoreRoutedCustomCalls(payload, names);
372
+ const restored = restoreRoutedCustomCalls(payload, names, repairNames, declaredNames);
261
373
  return restored.changed ? JSON.stringify(restored.value) : text;
262
374
  }
263
375
 
264
- export function unwrapRoutedCustomToolArguments(argumentsText: unknown): string {
265
- return customToolInput(argumentsText);
376
+ export function unwrapRoutedCustomToolArguments(
377
+ argumentsText: unknown,
378
+ toolName = "",
379
+ namespace?: string,
380
+ ): string {
381
+ return toolName
382
+ ? repairFreeformToolInput(argumentsText, toolName, namespace)
383
+ : unwrapFreeformToolInput(argumentsText);
266
384
  }
@@ -4,6 +4,14 @@ import { collectResponsesToolGroups } from "./tool-groups";
4
4
  export interface RoutedNamespaceToolIdentity {
5
5
  namespace: string;
6
6
  name: string;
7
+ /**
8
+ * The kind the tool was DECLARED as. Restoration and `tool_choice` matching both
9
+ * need it: a wire name identifies which tool, not which kind of call may carry it,
10
+ * so without this a tool declared `function` could be selected by a `custom`
11
+ * selector and come back as a `custom_tool_call` — the same name/kind mismatch
12
+ * that motivated narrowing the alias map in the first place.
13
+ */
14
+ kind: "function" | "custom";
7
15
  }
8
16
 
9
17
  export type RoutedNamespaceToolAliases = ReadonlyMap<string, RoutedNamespaceToolIdentity>;
@@ -138,7 +146,10 @@ function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan {
138
146
  addSelector(selectors, `${parsed.namespace}.${childName}`, wireName);
139
147
  addSelector(selectors, childName, wireName);
140
148
  if (parsed.namespace !== BUILTIN_FUNCTIONS_NAMESPACE) {
141
- aliases.set(wireName, { namespace: parsed.namespace, name: childName });
149
+ // A child declared `custom` stays custom; everything else lowers to a
150
+ // function, which is how `buildTools` flattens it upstream.
151
+ const kind = child.type === "custom" ? "custom" : "function";
152
+ aliases.set(wireName, { namespace: parsed.namespace, name: childName, kind });
142
153
  }
143
154
  }
144
155
  }
@@ -206,18 +217,34 @@ function rewriteToolList(
206
217
  * the failure this layer exists to prevent, and this layer's own response restoration is what put
207
218
  * the key on the item.
208
219
  */
220
+ /**
221
+ * A selector's `namespace` is either absent — meaning "unqualified, resolve the bare
222
+ * name" — or a string naming the group. A present-but-non-string value is neither, and
223
+ * a malformed selector must not authorize anything: not through the unqualified
224
+ * fallback, and not by happening to carry an already-flattened wire name, which would
225
+ * otherwise match the alias map exactly and arm it anyway.
226
+ */
227
+ function hasMalformedNamespace(value: Record<string, unknown>): boolean {
228
+ return "namespace" in value && typeof value.namespace !== "string";
229
+ }
230
+
209
231
  function rewriteNamedSelector(
210
232
  value: unknown,
211
233
  plan: NamespaceRewritePlan,
212
234
  bareFallback: boolean,
213
235
  ): unknown {
214
236
  if (!isPlainObject(value) || typeof value.name !== "string") return value;
215
- if (typeof value.namespace !== "string") {
237
+ // Malformed: hand it back untouched so no rewrite occurs. Authorization rejects it
238
+ // separately — returning it unchanged is not by itself enough, because the name it
239
+ // carries may already BE a wire name.
240
+ if (hasMalformedNamespace(value)) return value;
241
+ const namespace = value.namespace;
242
+ if (typeof namespace !== "string") {
216
243
  if (!bareFallback) return value;
217
244
  const wireName = plan.selectors.get(value.name) ?? undefined;
218
245
  return wireName === undefined || wireName === value.name ? value : { ...value, name: wireName };
219
246
  }
220
- const { namespace, ...rest } = value;
247
+ const { namespace: _dropped, ...rest } = value;
221
248
  const wireName = plan.identities.get(loweredIdentity(namespace, value.name))
222
249
  ?? loweredWireName(namespace, value.name);
223
250
  return { ...rest, name: wireName };
@@ -239,6 +266,59 @@ function rewriteToolChoice(value: unknown, plan: NamespaceRewritePlan): unknown
239
266
  return changed ? { ...value, tools } : value;
240
267
  }
241
268
 
269
+ /**
270
+ * Keep response restoration inside the caller's per-turn tool authorization boundary. The
271
+ * upstream sees every flattened declaration even when `tool_choice` narrows the tools it may call,
272
+ * so its output cannot be trusted merely because a wire name appeared in that catalog.
273
+ */
274
+ function authorizedAliases(
275
+ aliases: Map<string, RoutedNamespaceToolIdentity>,
276
+ toolChoice: unknown,
277
+ ): Map<string, RoutedNamespaceToolIdentity> {
278
+ if (toolChoice === undefined || toolChoice === "auto" || toolChoice === "required") return aliases;
279
+ if (toolChoice === "none" || !isPlainObject(toolChoice)) return new Map();
280
+
281
+ // name -> the kind the selector claimed. A selector authorizes a tool only when it
282
+ // names it AND agrees about what kind of tool it is.
283
+ let authorized: Map<string, "function" | "custom">;
284
+ if (
285
+ (toolChoice.type === "function" || toolChoice.type === "custom")
286
+ && typeof toolChoice.name === "string"
287
+ ) {
288
+ // A malformed namespace makes the whole selector untrustworthy, even when its
289
+ // name is already a flattened wire name that would match the alias map exactly.
290
+ if (hasMalformedNamespace(toolChoice)) return new Map();
291
+ authorized = new Map([[toolChoice.name, toolChoice.type]]);
292
+ } else if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) {
293
+ authorized = new Map();
294
+ for (const tool of toolChoice.tools) {
295
+ // Match the top-level branch above: only a function/custom selector can
296
+ // authorize a client namespace call. `allowed_tools` entries are typed
297
+ // `{type: string}` by the schema, so the accepted set is open-ended and
298
+ // an allowlist is the only closure that also covers kinds added later.
299
+ // Without this, `{type: "file_search", name: "<wire-name>"}` keeps the
300
+ // alias, and an upstream `function_call` carrying that name is restored
301
+ // into a namespace call the caller never permitted.
302
+ if (!isPlainObject(tool)) continue;
303
+ if (tool.type !== "function" && tool.type !== "custom") continue;
304
+ if (typeof tool.name !== "string") continue;
305
+ if (hasMalformedNamespace(tool)) continue;
306
+ authorized.set(tool.name, tool.type);
307
+ }
308
+ } else {
309
+ // An explicit selector for another tool kind does not authorize a client namespace call.
310
+ return new Map();
311
+ }
312
+
313
+ // The kind must agree too. A wire name says WHICH tool, not what kind of call may
314
+ // carry it, so a `custom` selector naming a tool declared `function` is the same
315
+ // name/kind mismatch as a `file_search` selector naming it — narrower, but the
316
+ // same class, and `allowed_tools[].type` accepts any string so both are reachable.
317
+ return new Map(
318
+ [...aliases].filter(([wireName, identity]) => authorized.get(wireName) === identity.kind),
319
+ );
320
+ }
321
+
242
322
  function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Set<string>): unknown {
243
323
  if (!isPlainObject(item)) return item;
244
324
  if (item.type === "additional_tools" && Array.isArray(item.tools)) {
@@ -292,7 +372,7 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
292
372
  ...(input !== body.input ? { input } : {}),
293
373
  ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}),
294
374
  },
295
- aliases: plan.aliases,
375
+ aliases: authorizedAliases(plan.aliases, toolChoice),
296
376
  };
297
377
  }
298
378