From 580afd735e1f391f955af4fd11df0f4d25a0a254 Mon Sep 17 00:00:00 2001 From: "liyongjie.103" Date: Tue, 25 Aug 2026 00:59:14 +0800 Subject: [PATCH] fix: normalize legacy exec_command/shell_command tool calls to declared exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 0.149 declares its code-mode shell tool as `exec` (a freeform custom tool whose description mentions the nested `await tools.exec_command(...)` helper). Routed models — DeepSeek in particular — sometimes echo that helper name as the tool-call name, emitting `exec_command` instead of the declared `exec`. The undeclared-tool guard then fails the whole turn with a 502. Normalize the legacy shell bridge names to `exec` at the three guard sites (streaming bridge x2, terminal snapshot guard) only when the request catalog declares `exec` and declares no legacy shell bridge name itself, so an MCP server advertising its own `exec_command` keeps working. Namespaced calls are always matched by their full wire name and never legacy-normalized. --- src/bridge.ts | 20 ++++++---- src/server/responses-undeclared-tool-guard.ts | 12 ++++-- src/types.ts | 1 + src/types/tools.ts | 27 +++++++++++++ tests/responses-undeclared-tool-guard.test.ts | 38 +++++++++++++++++++ 5 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/bridge.ts b/src/bridge.ts index b04bbb25ec9..bd24fb78e55 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -19,6 +19,7 @@ import { awaitThoughtSignatureDurability, } from "./responses/thought-signature-replay"; import { resolveStallTimeoutSec } from "./stall-timeout"; +import { normalizeDeclaredToolName } from "./types"; import { usageDisplayTotalTokens } from "./usage/totals"; import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources"; import { @@ -1041,13 +1042,14 @@ export function bridgeToResponsesSSE( rememberReasoningForCall(event.id, rawReasoningForNextToolCall, replayCacheScope); } if (currentToolCall) closeCurrentToolCall(); - const mapped = toolNsMap?.get(event.name); - const realName = mapped?.name ?? event.name; - if (options?.declaredToolNames && !options.declaredToolNames.has(event.name)) { + const effectiveName = normalizeDeclaredToolName(event.name, options?.declaredToolNames); + const mapped = toolNsMap?.get(effectiveName); + const realName = mapped?.name ?? effectiveName; + if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) { const failure = responseError( 502, "upstream_error", - `routed provider emitted undeclared client tool "${event.name}"; only request-declared tools may be called`, + `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`, ); emit("response.failed", { response: { @@ -1783,7 +1785,7 @@ function buildResponseJSONWithBudget( )); } break; - case "tool_call_start": + case "tool_call_start": { if (currentText) flushText("commentary"); if (currentSummaryReasoning) flushSummaryReasoning(); if (currentRawReasoning) flushRawReasoning(); @@ -1791,10 +1793,11 @@ function buildResponseJSONWithBudget( rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope); } flushToolCall(); - if (options?.declaredToolNames && !options.declaredToolNames.has(e.name)) { + const effectiveName = normalizeDeclaredToolName(e.name, options?.declaredToolNames); + if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) { errorEvent = { type: "error", - message: `routed provider emitted undeclared client tool "${e.name}"; only request-declared tools may be called`, + message: `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`, status: 502, errorType: "upstream_error", }; @@ -1802,11 +1805,12 @@ function buildResponseJSONWithBudget( } currentToolCallId = e.id; budget?.openCall(e.id); - currentToolCallName = e.name; + currentToolCallName = effectiveName; currentToolCallArgs = ""; currentToolCallArgsBytes = 0; currentToolCallProviderMetadata = e.providerMetadata; break; + } case "tool_call_delta": { ({ value: currentToolCallArgs, bytes: currentToolCallArgsBytes } = appendBatchString( diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 658a1c6bab5..8ef670d9c3a 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,4 +1,4 @@ -import { namespacedToolName } from "../types"; +import { namespacedToolName, normalizeDeclaredToolName } from "../types"; import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; /** Item types the client executes through a request-declared wire name. */ @@ -202,10 +202,14 @@ function undeclaredNameInItem( if (!CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined; const name = item.name; if (typeof name !== "string" || name.length === 0) return undefined; - if (declared.has(name)) return undefined; - if (typeof item.namespace === "string" && declared.has(namespacedToolName(item.namespace, name))) { - return undefined; + if (typeof item.namespace === "string") { + // Namespaced calls are matched by their full wire name only — never legacy-normalize + // them, or an undeclared namespaced `exec_command` could slip through as bare `exec`. + if (declared.has(namespacedToolName(item.namespace, name))) return undefined; + return name; } + const effectiveName = normalizeDeclaredToolName(name, declared); + if (declared.has(effectiveName)) return undefined; return name; } diff --git a/src/types.ts b/src/types.ts index 559e71cbc92..22a083098cb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export type { OcxTool, OcxToolChoice } from "./types/tools"; export { namespacedToolName, + normalizeDeclaredToolName, toolChoiceAliases, createToolChoiceResolver, toolChoiceCandidates, diff --git a/src/types/tools.ts b/src/types/tools.ts index 5e4f4547a08..89ebb3acb06 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -31,6 +31,33 @@ export function namespacedToolName(namespace: string | undefined, name: string): return namespace ? `${namespace}__${name}` : name; } +/** + * Codex 0.149 unified-exec name normalization. + * + * Codex's code-mode shell tool is declared as `exec` (a freeform custom tool whose own + * description mentions the nested `await tools.exec_command(...)` helper). Routed models — + * DeepSeek in particular — sometimes echo that helper name as the tool-call name, emitting + * `exec_command` instead of the declared `exec`. Accept the legacy shell bridge names only + * when the request catalog actually declares `exec` and does not itself declare the legacy + * name (an MCP server may legitimately advertise `exec_command` under its own namespace). + */ +const LEGACY_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const; + +export function normalizeDeclaredToolName( + name: string, + declared: ReadonlySet | undefined, +): string { + if (!declared || !declared.has("exec")) return name; + if (declared.has(name)) return name; + // When the catalog explicitly declares any legacy shell bridge name, the environment + // genuinely exposes that tool — turn normalization off so a call is never mis-routed + // to `exec`. + if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) { + return name; + } + return (LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(name) ? "exec" : name; +} + export function toolChoiceAliases(tool: Pick): string[] { const wireName = namespacedToolName(tool.namespace, tool.name); return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index f92b9fd0838..c31fe074e86 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -1247,4 +1247,42 @@ describe("undeclaredToolCallNameInResponse", () => { new Set(["computer_call"]), )).toBeUndefined(); }); + + test("accepts legacy shell bridge names when the catalog declares unified exec", () => { + // Codex 0.149 declares the code-mode shell tool as `exec`; routed models (DeepSeek) + // sometimes echo the nested helper name `exec_command` instead. The guard must accept + // it when the request catalog declares `exec` and does not itself declare the legacy + // name — but must still refuse it when the legacy name is a real declared tool. + const response = { + output: [ + { type: "function_call", name: "exec_command" }, + { type: "function_call", name: "shell_command" }, + ], + }; + + expect(undeclaredToolCallNameInResponse(response, new Set(["exec"]))).toBeUndefined(); + expect(undeclaredToolCallNameInResponse(response, new Set(["exec", "exec_command"]))).toBe( + "shell_command", + ); + expect(undeclaredToolCallNameInResponse(response, new Set(["exec_command"]))).toBe( + "shell_command", + ); + expect(undeclaredToolCallNameInResponse(response, new Set())).toBe("exec_command"); + }); + + test("never legacy-normalizes a namespaced shell bridge call", () => { + // A namespaced call (e.g. an MCP server advertising its own exec_command) must be + // matched by its full wire name only — never normalized to bare `exec`. + const namespaced = { + output: [ + { type: "function_call", name: "exec_command", namespace: "mcp__server" }, + { type: "function_call", name: "exec_command" }, + ], + }; + + expect(undeclaredToolCallNameInResponse(namespaced, new Set(["exec"]))).toBe( + "exec_command", + ); + expect(undeclaredToolCallNameInResponse(namespaced, new Set(["exec", "mcp__server__exec_command"]))).toBeUndefined(); + }); });