fix(sse): strip zero-width markers from streamed tool-call arguments (follow-up to #5857) (#6292)

Merged into release/v3.8.45. Strip zero-width markers from streamed tool-call arguments (#5857 follow-up). Test 50/50. CHANGELOG reconciled net +1. Reds pré-existentes (dast-smoke/file-size drift). Thanks @DKotsyuba.
This commit is contained in:
Denis Kotsyuba
2026-07-05 22:40:22 +02:00
committed by GitHub
parent 8e33393668
commit aabefc8026
3 changed files with 202 additions and 21 deletions

View File

@@ -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).

View File

@@ -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;

View File

@@ -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);
});