Files
diegosouzapw--omniroute/tests/unit/tool-name-case-preserve-4307.test.ts
2026-07-13 13:39:12 +08:00

100 lines
4.2 KiB
TypeScript

/**
* #4307 — Tool name case changes (`read` -> `Read`) leaks to the client.
*
* Native Claude OAuth traffic runs through the anti-fingerprint tool-name cloak
* (`remapToolNamesInRequest`/`cloakThirdPartyToolNames`), which renames a tool
* literally named `read` to `Read` and records the reverse alias (`Read -> read`)
* on a NON-ENUMERABLE `body._toolNameMap`. The response side un-cloaks the
* streamed `tool_use.name` back to the client's original casing using that map.
*
* Regression (v3.8.27 / #3941/#3968): `execute()` started returning a
* JSON-round-tripped `serializedBody` as `result.transformedBody`. The round-trip
* drops the non-enumerable `_toolNameMap`, so chatCore's response-side restore
* sees an empty map and the cloaked `Read` streams verbatim to the client — the
* tool name case is corrupted (`read` -> `Read`).
*
* This test pins the executor boundary: after `execute()` runs the claude-OAuth
* cloak, the returned `transformedBody` MUST still carry the per-request
* `_toolNameMap` (non-enumerable, so it never re-serializes upstream).
*/
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { BaseExecutor } from "../../open-sse/executors/base.ts";
// Minimal `claude` executor: passthrough transformRequest, no credential refresh,
// so we exercise exactly base.ts's cloak + serialize-and-return path.
class ClaudeLikeExecutor extends BaseExecutor {
constructor() {
super("claude", { baseUrls: ["https://api.anthropic.com/v1/messages"] });
}
// Never trigger the refresh branch in execute().
needsRefresh() {
return false;
}
async transformRequest(_model: string, body: Record<string, unknown>) {
return { ...body };
}
}
test("#4307 execute() preserves the tool-name cloak map (read->Read reverse) on the returned body", async () => {
const executor = new ClaudeLikeExecutor();
const originalFetch = globalThis.fetch;
let upstreamBody: Record<string, unknown> | null = null;
globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => {
upstreamBody = JSON.parse(String(init.body));
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
after(() => {
globalThis.fetch = originalFetch;
});
const result = await executor.execute({
model: "claude-sonnet-4-5",
body: {
messages: [{ role: "user", content: "hi" }],
// A tool literally named `read` (lower-case) — must round-trip unchanged
// back to the client.
tools: [{ name: "read", description: "read a file", input_schema: { type: "object" } }],
},
stream: false,
// OAuth token (sk-ant-oat…) with NO apiKey => hasClaudeOAuthToken => cloak fires.
credentials: { accessToken: "sk-ant-oat-test-token-4307" },
});
// Precondition: the cloak actually fired — the body sent UPSTREAM carries the
// TitleCase alias `Read` (this is intentional anti-fingerprint behavior).
assert.ok(upstreamBody, "fetch must have been called");
const upstreamToolName = (upstreamBody!.tools as Array<{ name: string }>)[0].name;
assert.equal(upstreamToolName, "Read", "precondition: cloak renames read -> Read on the wire");
// And the cloak map never re-serializes onto the wire (stays non-enumerable).
assert.equal(
JSON.stringify(upstreamBody).includes("_toolNameMap"),
false,
"_toolNameMap must never appear in the serialized upstream body"
);
// The actual regression guard: the returned transformedBody must still carry
// the reverse map so chatCore can restore `Read` -> `read` for the client.
const returned = result.transformedBody as Record<string, unknown>;
const map = returned._toolNameMap;
assert.ok(
map instanceof Map,
"result.transformedBody must carry the non-enumerable _toolNameMap (dropped by the v3.8.27 serialize round-trip without the #4307 fix)"
);
assert.equal(
(map as Map<string, string>).get("Read"),
"read",
"reverse map must restore the client's original tool-name casing"
);
// The re-attached map must remain non-enumerable (never re-serializes upstream).
assert.equal(
Object.keys(returned).includes("_toolNameMap"),
false,
"_toolNameMap must stay non-enumerable on the returned body"
);
});