mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user