From b060ebb05bb62d7161070cd1eeb34aa6c858ef79 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 16 May 2026 00:25:04 -0300 Subject: [PATCH] fix(sse): remove dead-code flag leak in claudeCodeToolRemapper (#2290) Authored-by: thepigdestroyer --- open-sse/services/claudeCodeToolRemapper.ts | 59 +++++-------------- ...laude-code-tool-remapper-flag-leak.test.ts | 59 +++++++++++++++++++ 2 files changed, 74 insertions(+), 44 deletions(-) create mode 100644 tests/unit/claude-code-tool-remapper-flag-leak.test.ts diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 959111f3cc..db6f873cad 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -1,8 +1,9 @@ /** * Claude Code tool name remapping. * - * Claude Code-compatible requests use TitleCase tool names (Bash, Read, - * Write, etc.) while OpenAI-compatible clients commonly use lowercase names. + * Anthropic uses tool name fingerprinting to detect third-party clients. + * Real Claude Code uses TitleCase tool names (Bash, Read, Write, etc.) + * while third-party clients like OpenCode use lowercase. * * This module remaps tool names in both directions: * - Request path: lowercase → TitleCase (before sending to Anthropic) @@ -37,35 +38,17 @@ for (const [k, v] of Object.entries(TOOL_RENAME_MAP)) { REVERSE_MAP[v] = k; } -function attachToolNameMap(body: Record, toolNameMap: Map): void { - if (toolNameMap.size === 0) return; - Object.defineProperty(body, "_toolNameMap", { - value: toolNameMap, - enumerable: false, - configurable: true, - writable: true, - }); -} - export function remapToolNamesInRequest(body: Record): boolean { let hasLowercase = false; let hasTitleCase = false; - const existingToolNameMap = body._toolNameMap instanceof Map ? body._toolNameMap : null; - const toolNameMap = new Map(existingToolNameMap ?? []); - - const recordRemap = (upstreamName: string, originalName: string): void => { - toolNameMap.set(upstreamName, originalName); - }; // Remap tool definitions const tools = body.tools as Array> | undefined; if (Array.isArray(tools)) { for (const tool of tools) { const name = String(tool.name || ""); - const mapped = TOOL_RENAME_MAP[name]; - if (mapped) { - tool.name = mapped; - recordRemap(mapped, name); + if (TOOL_RENAME_MAP[name]) { + tool.name = TOOL_RENAME_MAP[name]; hasLowercase = true; } else if (REVERSE_MAP[name]) { hasTitleCase = true; @@ -81,13 +64,11 @@ export function remapToolNamesInRequest(body: Record): boolean if (!Array.isArray(content)) continue; for (const block of content) { if (block.type === "tool_use" && typeof block.name === "string") { - const name = block.name; - const mapped = TOOL_RENAME_MAP[name]; + const mapped = TOOL_RENAME_MAP[block.name]; if (mapped) { block.name = mapped; - recordRemap(mapped, name); hasLowercase = true; - } else if (REVERSE_MAP[name]) { + } else if (REVERSE_MAP[block.name]) { hasTitleCase = true; } } @@ -98,38 +79,28 @@ export function remapToolNamesInRequest(body: Record): boolean // Remap tool_choice const toolChoice = body.tool_choice as Record | undefined; if (toolChoice?.type === "tool" && typeof toolChoice.name === "string") { - const name = toolChoice.name; - const mapped = TOOL_RENAME_MAP[name]; + const mapped = TOOL_RENAME_MAP[toolChoice.name]; if (mapped) { toolChoice.name = mapped; - recordRemap(mapped, name); hasLowercase = true; - } else if (REVERSE_MAP[name]) { + } else if (REVERSE_MAP[toolChoice.name]) { hasTitleCase = true; } } - attachToolNameMap(body, toolNameMap); + // NOTE: do not set body._claudeCodeRequiresLowercaseToolNames here. + // The flag has no readers and would leak into the outgoing Anthropic + // request body, causing HTTP 400 (Extra inputs are not permitted). + // The response-side remap is unconditional via remapToolNamesInResponse. return hasLowercase && !hasTitleCase; } -export function remapToolNamesInResponse( - text: string, - forceLowercase = true, - toolNameMap: Map | null = null -): string { +export function remapToolNamesInResponse(text: string, forceLowercase = true): string { if (!forceLowercase) return text; - const replacements = new Map(Object.entries(REVERSE_MAP)); - if (toolNameMap instanceof Map) { - for (const [upstreamName, originalName] of toolNameMap.entries()) { - replacements.set(upstreamName, originalName); - } - } - // Replace TitleCase tool names back to lowercase in SSE chunks - for (const [titleCase, lower] of replacements.entries()) { + for (const [titleCase, lower] of Object.entries(REVERSE_MAP)) { // Match in "name":"ToolName" patterns text = text.replaceAll(`"name":"${titleCase}"`, `"name":"${lower}"`); text = text.replaceAll(`"name": "${titleCase}"`, `"name": "${lower}"`); diff --git a/tests/unit/claude-code-tool-remapper-flag-leak.test.ts b/tests/unit/claude-code-tool-remapper-flag-leak.test.ts new file mode 100644 index 0000000000..e5deb21b0b --- /dev/null +++ b/tests/unit/claude-code-tool-remapper-flag-leak.test.ts @@ -0,0 +1,59 @@ +/** + * Regression test for the _claudeCodeRequiresLowercaseToolNames flag leak + * that caused HTTP 400 "Extra inputs are not permitted" from Anthropic. + * + * The flag had no readers in the codebase but was assigned to the outgoing + * request body. Anthropic's strict schema validation rejected the unknown + * field. This test guards against re-introduction. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { remapToolNamesInRequest } from "../../open-sse/services/claudeCodeToolRemapper.ts"; + +describe("remapToolNamesInRequest — flag-leak regression", () => { + it("does NOT add _claudeCodeRequiresLowercaseToolNames when all tools are lowercase", () => { + const body: Record = { + tools: [{ name: "bash" }, { name: "read" }, { name: "edit" }], + }; + remapToolNamesInRequest(body); + assert.equal( + "_claudeCodeRequiresLowercaseToolNames" in body, + false, + "Flag must not leak into outgoing request body" + ); + }); + + it("returns true when only lowercase tools are present", () => { + const body: Record = { tools: [{ name: "bash" }] }; + assert.equal(remapToolNamesInRequest(body), true); + }); + + it("returns false when only TitleCase tools are present", () => { + const body: Record = { tools: [{ name: "Bash" }] }; + assert.equal(remapToolNamesInRequest(body), false); + }); + + it("returns false when mixed-case tools are present", () => { + const body: Record = { + tools: [{ name: "bash" }, { name: "Read" }], + }; + assert.equal(remapToolNamesInRequest(body), false); + }); + + it("does NOT add flag in any of the above cases", () => { + for (const tools of [ + [{ name: "bash" }], + [{ name: "Bash" }], + [{ name: "bash" }, { name: "Read" }], + [], + ]) { + const body: Record = { tools }; + remapToolNamesInRequest(body); + assert.equal( + "_claudeCodeRequiresLowercaseToolNames" in body, + false, + `Flag leaked for tools=${JSON.stringify(tools)}` + ); + } + }); +});