diff --git a/CHANGELOG.md b/CHANGELOG.md index b7dbb74db7..9a57f71b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ ### 🐛 Bug Fixes +- **fix(sse):** strip zero-width markers from streamed **tool-call arguments** — a follow-up to [#5857](https://github.com/diegosouzapw/OmniRoute/pull/5857). That PR removed injected zero-width joiners (U+200D) from streamed assistant text/reasoning but deliberately left tool-call argument JSON byte-exact. The request-side obfuscation (`open-sse/services/claudeCodeObfuscation.ts`) injects ZWJ into agent words — including the temp path inside the Bash tool description — and Claude models copy that verbatim into generated commands, which are delivered as tool-call arguments rather than assistant text. As a result the ZWJ survived and corrupted code blocks (e.g. a temp path rendered with an invisible joiner). Now `open-sse/handlers/responseSanitizer.ts` strips zero-width code points from tool-call argument strings at every emit site (OpenAI non-stream/stream chat `tool_calls` + legacy `function_call`, native Responses `function_call` items, the OpenAI→Responses conversion, and the native Responses streaming `response.function_call_arguments.delta/.done` events). Only zero-width code points are removed; JSON structure and all other bytes stay identical (no parse/restringify), so normal arguments remain byte-exact. Regression guard: 6 new cases in `tests/unit/response-sanitizer.test.ts` (suite 50/50). + - **fix(nodejs):** the default app log path now resolves under `DATA_DIR` (`~/.omniroute/logs/application/app.log`) instead of `process.cwd()` ([#6197](https://github.com/diegosouzapw/OmniRoute/issues/6197)) — the globally-installed CLI runs from an arbitrary working directory, so anchoring the default to cwd made file logging silently write to (or no-op under) an unrelated directory, contradicting the documented `.env.example` default. `getAppLogFilePath()` now computes the default lazily via the pure `resolveDataDir()` resolver (honours a per-process `DATA_DIR`, no directory-creation side effect); an explicit `APP_LOG_FILE_PATH` still wins. Regression guard: `tests/unit/logenv-datadir-path-6197.test.ts` (3). - **fix(docker):** AgentBridge/`startMitm` no longer aborts in containers/headless when the Antigravity-default DNS step can't write `/etc/hosts` ([#6127](https://github.com/diegosouzapw/OmniRoute/issues/6127)), and the privileged command's stderr now reaches `app.log` instead of only a bare exit code hitting the toast ([#6198](https://github.com/diegosouzapw/OmniRoute/issues/6198)). The default DNS step (`addDNSEntry`) was called unguarded while cert install and the two sibling DNS steps were each best-effort — in the runtime Docker image (`USER node`, no `sudo`, read-only `/etc/hosts`) it threw `Command failed with code 1` out of `startMitmInternal` and killed the whole start, discarding the stderr. The three DNS steps are extracted into a best-effort `provisionDnsEntries()` where each failure is logged with the full `err` (stderr included, folded in by `systemCommands.ts`) and never aborts the start. Regression guard: `tests/unit/mitm-dns-graceful-degrade-6127.test.ts` (4). diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index f45ae5d3fe..25fcb0d616 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -68,6 +68,27 @@ function stripZeroWidthText(value: string): string { return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); } +function stripZeroWidthToolArgumentJson(value: unknown): string { + return stripZeroWidthText(typeof value === "string" ? value : JSON.stringify(value || {})); +} + +function stripZeroWidthFunctionArguments(functionCall: unknown): unknown { + const fn = toRecord(functionCall); + if (!fn || typeof fn.arguments !== "string") return functionCall; + const stripped = stripZeroWidthText(fn.arguments); + // Fast path: return the original reference when there is nothing to strip, so + // hot streaming paths avoid a per-chunk shallow clone of every tool call. + if (stripped === fn.arguments) return functionCall; + return { ...fn, arguments: stripped }; +} + +function stripZeroWidthToolCallArguments(toolCall: unknown): unknown { + const tc = toRecord(toolCall); + if (!tc) return toolCall; + const fn = stripZeroWidthFunctionArguments(tc.function); + return fn === tc.function ? toolCall : { ...tc, function: fn }; +} + function stripZeroWidthValue(value: unknown): unknown { if (typeof value === "string") return stripZeroWidthText(value); if (Array.isArray(value)) return value.map((item) => stripZeroWidthValue(item)); @@ -418,11 +439,13 @@ function sanitizeMessage(msg: unknown, options: ParseOptions = {}): unknown { applyTextualToolCallSanitization(sanitized, msgRecord); if (msgRecord.tool_calls) { - sanitized.tool_calls = msgRecord.tool_calls; + sanitized.tool_calls = Array.isArray(msgRecord.tool_calls) + ? msgRecord.tool_calls.map((toolCall) => stripZeroWidthToolCallArguments(toolCall)) + : msgRecord.tool_calls; } if (msgRecord.function_call) { - sanitized.function_call = msgRecord.function_call; + sanitized.function_call = stripZeroWidthFunctionArguments(msgRecord.function_call); } return sanitized; @@ -627,10 +650,7 @@ function sanitizeResponsesStreamingOutputItem(item: unknown): JsonRecord | null return { ...itemRecord, type: "function_call", - arguments: - typeof itemRecord.arguments === "string" - ? itemRecord.arguments - : JSON.stringify(itemRecord.arguments || {}), + arguments: stripZeroWidthToolArgumentJson(itemRecord.arguments), }; } @@ -659,8 +679,8 @@ function sanitizeResponsesStreamingOutput(output: unknown): JsonRecord[] { // Native Responses streaming events that carry raw model text directly on the // root `delta` field. These must get the same zero-width-joiner stripping as the // non-streaming path so agent words (opencode/cursor/aider) are not corrupted. -// Scoped as an allow-list on purpose: `response.function_call_arguments.delta` -// carries tool-call argument JSON that must pass through byte-exact. +// Scoped as an allow-list on purpose: other root `delta` events may carry non-text payloads. +// Function-call argument events are handled separately by stripping only zero-width code points. const RESPONSES_STREAMING_TEXT_DELTA_EVENTS = new Set([ "response.output_text.delta", "response.reasoning_summary_text.delta", @@ -688,6 +708,18 @@ function sanitizeResponsesStreamingEvent(parsedRecord: JsonRecord): JsonRecord { if (RESPONSES_STREAMING_TEXT_DONE_EVENTS.has(eventType) && typeof sanitized.text === "string") { sanitized.text = stripZeroWidthText(sanitized.text); } + if ( + eventType === "response.function_call_arguments.delta" && + typeof sanitized.delta === "string" + ) { + sanitized.delta = stripZeroWidthText(sanitized.delta); + } + if ( + eventType === "response.function_call_arguments.done" && + typeof sanitized.arguments === "string" + ) { + sanitized.arguments = stripZeroWidthText(sanitized.arguments); + } if (parsedRecord.item !== undefined) { const sanitizedItem = sanitizeResponsesStreamingOutputItem(parsedRecord.item); @@ -786,10 +818,7 @@ function sanitizeResponsesOutputItem(item: unknown, index: number): JsonRecord | type: "function_call", call_id: callId, name: toString(itemRecord.name) || "", - arguments: - typeof itemRecord.arguments === "string" - ? itemRecord.arguments - : JSON.stringify(itemRecord.arguments || {}), + arguments: stripZeroWidthToolArgumentJson(itemRecord.arguments), }; } @@ -931,8 +960,7 @@ function convertOpenAIResponseToResponses(openaiResponse: JsonRecord): JsonRecor type: "function_call", call_id: callId, name: toString(fn.name) || "", - arguments: - typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}), + arguments: stripZeroWidthToolArgumentJson(fn.arguments), }); } @@ -1019,15 +1047,17 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { ? deltaRecord.tool_calls.map((tc) => { const t = toRecord(tc); if (!t) return tc; + const strippedToolCall = stripZeroWidthToolCallArguments(t); + const strippedRecord = toRecord(strippedToolCall) || t; if (t.id !== undefined && t.id !== null && typeof t.id !== "string") { - return { ...t, id: String(t.id) }; + return { ...strippedRecord, id: String(t.id) }; } - return t; + return strippedRecord; }) : deltaRecord.tool_calls; } if (deltaRecord.function_call !== undefined) - delta.function_call = deltaRecord.function_call; + delta.function_call = stripZeroWidthFunctionArguments(deltaRecord.function_call); c.delta = delta; } else { c.delta = choiceRecord.delta; diff --git a/tests/unit/response-sanitizer.test.ts b/tests/unit/response-sanitizer.test.ts index 18ad4d6283..84305e6555 100644 --- a/tests/unit/response-sanitizer.test.ts +++ b/tests/unit/response-sanitizer.test.ts @@ -894,12 +894,161 @@ test("sanitizeStreamingChunk strips zero-width joiners from response.reasoning_s assert.equal(output.includes("\u200d"), false); }); -test("sanitizeStreamingChunk leaves function_call_arguments.delta byte-exact (tool args must not be corrupted)", () => { +test("sanitizeStreamingChunk strips zero-width joiners from native response.function_call_arguments.delta", () => { const sanitized = sanitizeStreamingChunk({ type: "response.function_call_arguments.delta", - delta: '{"path":"o\u200dpencode"}', + delta: '{"command":"o\u200d', }) as any; + const output = JSON.stringify(sanitized); - assert.equal(sanitized.delta, '{"path":"o\u200dpencode"}'); - assert.equal((sanitized.delta as string).includes("\u200d"), true); + assert.equal(sanitized.delta, '{"command":"o'); + assert.equal(output.includes("\u200d"), false); +}); + +test("sanitizeStreamingChunk strips zero-width joiners from native response.function_call_arguments.done", () => { + const sanitized = sanitizeStreamingChunk({ + type: "response.function_call_arguments.done", + arguments: '{"command":"o\u200dpencode"}', + }) as any; + const output = JSON.stringify(sanitized); + + assert.equal(sanitized.arguments, '{"command":"opencode"}'); + assert.equal(output.includes("\u200d"), false); +}); + +test("sanitizeStreamingChunk strips zero-width joiners from OpenAI chat tool-call argument deltas", () => { + const sanitized = sanitizeStreamingChunk({ + id: "chunk_tool", + object: "chat.completion.chunk", + model: "claude-sonnet", + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { + name: "run", + arguments: '{"command":"cd /tmp/o\u200dpencode && pwd"}', + }, + }, + ], + }, + }, + ], + }) as any; + const output = JSON.stringify(sanitized); + + assert.equal( + sanitized.choices[0].delta.tool_calls[0].function.arguments, + '{"command":"cd /tmp/opencode && pwd"}' + ); + assert.equal(output.includes("\u200d"), false); +}); + +test("sanitizeOpenAIResponse strips zero-width joiners from non-stream tool-call arguments", () => { + const sanitized = sanitizeOpenAIResponse({ + id: "chatcmpl_zwj", + model: "claude-sonnet", + choices: [ + { + index: 0, + finish_reason: "tool_calls", + message: { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "run", + arguments: '{"command":"o\u200dpencode"}', + }, + }, + ], + }, + }, + ], + }) as any; + const output = JSON.stringify(sanitized); + + assert.equal( + sanitized.choices[0].message.tool_calls[0].function.arguments, + '{"command":"opencode"}' + ); + assert.equal(output.includes("\u200d"), false); +}); + +test("sanitizeResponsesApiResponse strips zero-width joiners from native function_call output item arguments", () => { + const sanitized = sanitizeResponsesApiResponse({ + id: "resp_zwj", + object: "response", + model: "gpt-5.1-codex", + status: "completed", + output: [ + { + id: "fc_1", + type: "function_call", + call_id: "call_1", + name: "run", + arguments: '{"command":"o\u200dpencode"}', + }, + ], + }) as any; + const output = JSON.stringify(sanitized); + + assert.equal(sanitized.output[0].arguments, '{"command":"opencode"}'); + assert.equal(output.includes("\u200d"), false); +}); + +test("sanitizer leaves normal tool arguments byte-identical (no parse/restringify)", () => { + const rawArgs = '{ "command" : "printf \\"hello\\" && ls -ll", "path" : "/tmp/opencode" }'; + + const streamed = sanitizeStreamingChunk({ + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "run", arguments: rawArgs }, + }, + ], + }, + }, + ], + }) as any; + assert.equal(streamed.choices[0].delta.tool_calls[0].function.arguments === rawArgs, true); + + const nonStream = sanitizeOpenAIResponse({ + id: "chatcmpl_identity", + model: "claude-sonnet", + choices: [ + { + index: 0, + finish_reason: "tool_calls", + message: { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "run", arguments: rawArgs }, + }, + ], + }, + }, + ], + }) as any; + assert.equal(nonStream.choices[0].message.tool_calls[0].function.arguments === rawArgs, true); });