From 09c015cea82c286f0231e308af1d866df1dca4ce Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 16 Oct 2026 20:42:41 +0330 Subject: [PATCH 1/2] fix(opencode): normalize colliding fingerprint tool definitions in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode composes its `shell` tool description from `process.platform`, so a Windows or macOS client never matches the captured Linux definition. Every such request was treated as a conflicting caller tool: `shell` was renamed to `shell_client`, a placeholder `shell` was appended, and a system note told the model to avoid it. Besides leaking an internal rewrite into the prompt, the alias name is derived from the caller's tool names, so a client that already declares `shell_client` (a plugin, a harness shim) pushed the alias to `shell_client2`/3, and a call to the advertised name could reach the client as a tool it does not have. Adopt OpenCode's definition in place whenever the caller's parameters can carry it — the platform-wording case, and the common one. Genuinely different schemas keep the alias + restore path unchanged. - `read` on every platform, and `shell` on Linux, stay byte-identical and are still passed straight through. - Adds regression tests for platform drift, the plugin alias collision, and the schema-incompatible fallback. --- docs/ARCHITECTURE.md | 1 + open-sse/utils/opencodeFingerprint.js | 64 ++++++++++-- tests/unit/opencode-fingerprint.test.js | 123 ++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 9 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 548c31908d7..2d4e8a0f815 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,6 +546,7 @@ Environment variables actively used by code: 2. `/api/v1/route.js` returns a static model list and is not the main models source used by `/v1/models`. 3. Request logger writes full headers/body when enabled; treat log directory as sensitive. 4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability. +5. The OpenCode free-tier fingerprint in `open-sse/utils/opencodeFingerprint.js` rewrites `tools` before the upstream call: a caller tool that already uses a fingerprint name (`read`/`shell`) adopts OpenCode's definition when its parameters can carry them, otherwise it is aliased and restored on the response side. The definitions are a frozen capture of the OpenCode CLI, so `shell`'s description embeds the capture platform (`Linux`/`bash`) and is rewritten for clients on any other platform. ## Operational Verification Checklist diff --git a/open-sse/utils/opencodeFingerprint.js b/open-sse/utils/opencodeFingerprint.js index 184bbe0542c..99b829100f7 100644 --- a/open-sse/utils/opencodeFingerprint.js +++ b/open-sse/utils/opencodeFingerprint.js @@ -5,9 +5,10 @@ * requests must declare OpenCode's own `read` and `shell` tools with their exact * descriptions and parameter schemas; stubbed descriptions, renamed schemas or * tool_choice "none" are answered with 403 FreeTierError. Caller tools are sent - * untouched, except one that already uses an exact fingerprint name with a - * different definition: it travels under an alias and the response side - * restores the caller's original name. + * untouched. A tool that already uses an exact fingerprint name with a + * different definition adopts OpenCode's definition in place whenever its + * arguments can carry them; only a genuinely different schema travels under an + * alias, and the response side restores that caller's original name. */ /** Names required by the upstream free-tier gate. */ @@ -86,10 +87,46 @@ function withToolName(tool, name) { } /** - * Move caller tools off the fingerprint names when their definition differs - * from OpenCode's, so the exact definitions can be appended without a - * duplicate name. Everything else, including case variants such as `Read`, is - * preserved verbatim; upstream accepts them alongside `read`. + * True when `tool` already speaks OpenCode's parameter names for `key`, so the + * fingerprint definition can replace its own without hiding or adding an + * argument the caller's handler expects. + * + * This is the common case, not an edge: OpenCode composes `shell`'s description + * from `process.platform`, so the very same tool run on Windows reports + * "Commands run on Windows using pwsh." while the captured definition says + * "Commands run on Linux using bash." — a two-word difference that an exact + * string compare rejects. + */ +function canNormalizeInPlace(tool, key) { + const def = tool && (chatFunctionOf(tool) || tool); + const props = def?.parameters?.properties; + if (!props || typeof props !== "object" || Array.isArray(props)) return false; + + const wanted = Object.keys(FINGERPRINT_TOOL_DEFINITIONS[key].parameters.properties || {}); + const declared = Object.keys(props); + if (declared.some((name) => !wanted.includes(name))) return false; + return FINGERPRINT_TOOL_DEFINITIONS[key].parameters.required.every((name) => declared.includes(name)); +} + +/** + * Replace `tool`'s definition with OpenCode's while keeping its name, so the + * wire carries a single, gate-satisfying tool instead of a caller tool plus a + * placeholder of the same purpose. + */ +function withFingerprintDefinition(tool, key) { + const { description, parameters } = FINGERPRINT_TOOL_DEFINITIONS[key]; + const fn = chatFunctionOf(tool); + return fn + ? { ...tool, function: { ...fn, description, parameters } } + : { ...tool, description, parameters }; +} + +/** + * Keep caller tools on the fingerprint names whenever their arguments can carry + * OpenCode's, and move the remaining ones off them so the exact definitions can + * be appended without a duplicate name. Everything else, including case + * variants such as `Read`, is preserved verbatim; upstream accepts them + * alongside `read`. * * @param {Array} tools * @returns {{ tools: Array, map: Map }} map: sent name -> original name @@ -122,6 +159,14 @@ export function concealFingerprintToolNames(tools) { continue; } + // Same tool, different wording. Adopt OpenCode's definition in place: the + // caller keeps its own name, so there is no alias to restore, no alias name + // for the model to collide with, and no placeholder to explain away. + if (canNormalizeInPlace(tool, key)) { + out.push(withFingerprintDefinition(tool, key)); + continue; + } + let alias = `${key}_client`; for (let i = 2; taken.has(alias); i++) alias = `${key}_client${i}`; taken.add(alias); @@ -231,8 +276,9 @@ function noteUnavailableTools(body, flat, names) { } /** - * Full request-side pass: alias conflicting caller tools, append OpenCode's - * fingerprint definitions, and tell the model the appended ones are off-limits. + * Full request-side pass: normalize caller tools that collide with a + * fingerprint name, append OpenCode's definitions for any that are missing, and + * tell the model the appended ones are off-limits. * * @param {object} body * @param {boolean} flat - true for Responses tools ({name}), false for chat tools diff --git a/tests/unit/opencode-fingerprint.test.js b/tests/unit/opencode-fingerprint.test.js index 826b93af916..611f6cff7b1 100644 --- a/tests/unit/opencode-fingerprint.test.js +++ b/tests/unit/opencode-fingerprint.test.js @@ -121,6 +121,129 @@ describe("opencodeFingerprint — request side", () => { }); }); +describe("opencodeFingerprint — in-place normalization", () => { + // OpenCode builds shell's description from process.platform, so the very same + // tool on Windows or macOS never matches the captured Linux definition. + const shellTool = (os, shell, name = "shell") => ({ + type: "function", + name, + description: + "Execute a shell command and return its output. Commands run on " + os + " using " + shell + ". Quote file paths containing spaces or special characters. Prefer dedicated tools over shell commands when possible. When output is large, the full result is saved to a file and a truncated preview is returned. Rely on automatic truncation unless filtering the output is more useful. Commands accept an optional timeout, background commands have no timeout by default. Background commands return immediately, and you will be notified when they complete.", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "Shell command string to execute" }, + workdir: { type: "string", description: "Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead." }, + timeout: { type: "integer", minimum: 0, description: "Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to 120000 for foreground commands. Background commands have no timeout by default." }, + background: { type: "boolean", description: "Run the command in the background and return immediately (useful for dev servers and long-running builds). You do not need to use '&' at the end of the command when using this parameter. You will be notified when it completes. DO NOT poll for completion." }, + }, + required: ["command"], + additionalProperties: false, + }, + }); + + const asChat = (tool) => ({ + type: tool.type, + function: { name: tool.name, description: tool.description, parameters: tool.parameters }, + }); + const windowsShell = () => shellTool("Windows", "pwsh"); + + it("keeps the caller's name and adopts OpenCode's definition when arguments line up", () => { + const body = { tools: [windowsShell(), { type: "function", name: "Edit" }] }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["shell", "Edit", "read"]); + expect(map.size).toBe(0); + expect(takeRenamedToolNames(body)).toBeNull(); + expect(body.instructions).toBeUndefined(); + expect(body.tools[0].description).toContain("Commands run on Linux using bash."); + expect(body.tools[0].parameters.required).toEqual(["command"]); + }); + + it("normalizes the nested chat shape without adding a system note", () => { + const body = { tools: [asChat(windowsShell())] }; + const map = applyFingerprintTools(body, false); + + expect(namesOf(body.tools)).toEqual(["shell", "read"]); + expect(map.size).toBe(0); + expect(body.messages).toBeUndefined(); + expect(body.tools[0].function.description).toContain("Commands run on Linux using bash."); + }); + + it("wires a real OpenCode client down to exactly read + shell", () => { + const seeded = { tools: [] }; + applyFingerprintTools(seeded, false); + const officialRead = seeded.tools.find((tool) => tool.function?.name === "read"); + + const body = { tools: [officialRead, asChat(windowsShell())] }; + const map = applyFingerprintTools(body, false); + + expect(namesOf(body.tools)).toEqual(["read", "shell"]); + expect(map.size).toBe(0); + expect(body.messages).toBeUndefined(); + }); + + it("no longer invents alias names that a client plugin may already own", () => { + const pluginTool = (name) => ({ + type: "function", + name, + description: "Execute a shell command and return its output.", + parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }, + }); + const body = { tools: [pluginTool("shell_client"), pluginTool("shell_client2"), windowsShell()] }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["shell_client", "shell_client2", "shell", "read"]); + expect(map.size).toBe(0); + }); + + it("still conceals a fingerprint name whose arguments do not line up", () => { + const body = { + tools: [{ + type: "function", + name: "shell", + description: "Run a command", + parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] }, + }], + }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["shell_client", "read", "shell"]); + expect(map.get("shell_client")).toBe("shell"); + expect(body.instructions).toContain("Do not call the"); + }); + + it("conceals when the caller declares an argument the fingerprint does not", () => { + const body = { + tools: [{ + type: "function", + name: "read", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" }, encoding: { type: "string" } }, required: ["path"] }, + }], + }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["read_client", "read", "shell"]); + expect(map.get("read_client")).toBe("read"); + }); + + it("conceals when the caller is missing a required fingerprint argument", () => { + const body = { + tools: [{ + type: "function", + name: "read", + description: "Read a file", + parameters: { type: "object", properties: {}, required: [] }, + }], + }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["read_client", "read", "shell"]); + expect(map.get("read_client")).toBe("read"); + }); +}); + describe("opencodeFingerprint — response side", () => { const map = new Map([["bash", "Bash"], ["grep", "Grep"], ["read", "Read"]]); From 7854d502d50f80f30df2d34f95be8a29171fe9b9 Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 16 Oct 2026 20:47:37 +0330 Subject: [PATCH 2/2] test(opencode): assert the placeholder note names only the absent fingerprint tool --- tests/unit/opencode-fingerprint.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/opencode-fingerprint.test.js b/tests/unit/opencode-fingerprint.test.js index 611f6cff7b1..cb16f854375 100644 --- a/tests/unit/opencode-fingerprint.test.js +++ b/tests/unit/opencode-fingerprint.test.js @@ -155,9 +155,10 @@ describe("opencodeFingerprint — in-place normalization", () => { expect(namesOf(body.tools)).toEqual(["shell", "Edit", "read"]); expect(map.size).toBe(0); expect(takeRenamedToolNames(body)).toBeNull(); - expect(body.instructions).toBeUndefined(); expect(body.tools[0].description).toContain("Commands run on Linux using bash."); expect(body.tools[0].parameters.required).toEqual(["command"]); + // Only the genuinely absent `read` is still advertised as a placeholder. + expect(body.instructions).toBe("Do not call the `read` tool; it is a placeholder that does nothing. Use the other tools you have been given instead."); }); it("normalizes the nested chat shape without adding a system note", () => {