mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge PR 2943 into release/v3.8.8
This commit is contained in:
@@ -683,6 +683,14 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# Update these when providers release new CLI versions to avoid blocks.
|
||||
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.146 (external, cli)"
|
||||
|
||||
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
|
||||
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
|
||||
# third-party-harness tool names are aliased to
|
||||
# Claude Code canonical or PascalCase forms so Anthropic does not refuse the
|
||||
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
|
||||
# forward the original names verbatim (debugging only).
|
||||
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
|
||||
CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)"
|
||||
GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1"
|
||||
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
|
||||
|
||||
@@ -435,6 +435,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`]
|
||||
| Variable | Default Value | When to Update |
|
||||
| ------------------------ | --------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version |
|
||||
| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. |
|
||||
| `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI |
|
||||
| `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string |
|
||||
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates |
|
||||
|
||||
@@ -21,8 +21,9 @@ import {
|
||||
modelSupportsContext1mBeta,
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults";
|
||||
import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts";
|
||||
import { cloakThirdPartyToolNames, remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts";
|
||||
import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts";
|
||||
import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts";
|
||||
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
||||
import { applySystemTransformPipeline, PROVIDER_CLAUDE } from "../services/systemTransforms.ts";
|
||||
import {
|
||||
@@ -775,6 +776,13 @@ export class BaseExecutor {
|
||||
|
||||
stripProxyToolPrefix(tb);
|
||||
remapToolNamesInRequest(tb);
|
||||
// Cloak third-party tool names + sanitize invalid tool schemas so
|
||||
// Anthropic does not refuse native Claude OAuth traffic with a
|
||||
// misleading "out of extra usage" placeholder. See Spec E.
|
||||
cloakThirdPartyToolNames(tb);
|
||||
if (Array.isArray(tb.tools)) {
|
||||
tb.tools = sanitizeClaudeToolSchemas(tb.tools);
|
||||
}
|
||||
obfuscateInBody(tb);
|
||||
|
||||
// NOTE (issue #2260): This is the native `claude` provider OAuth path.
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { cloakThirdPartyToolNames } from "../services/claudeCodeToolRemapper.ts";
|
||||
import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts";
|
||||
|
||||
const DEFAULT_PORT = 8317;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
@@ -335,9 +337,38 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
// uses (utils/stream.ts:restoreClaudePassthroughToolUseName) to
|
||||
// rewrite tool_use.name back to the client's original namespace on
|
||||
// the response side. Capy sees mcp_call back in tool_use blocks.
|
||||
const toolNameMap = applyMcpToolNameRewrite(transformed);
|
||||
// Sanitize invalid tool input_schemas (truncation placeholders such as
|
||||
// `enum: "[MaxDepth]"`, or index-keyed objects where arrays are required)
|
||||
// that Anthropic rejects with `tools.N.custom.input_schema: JSON schema is
|
||||
// invalid` — surfaced as the same misleading "out of extra usage" 400.
|
||||
if (Array.isArray(transformed.tools)) {
|
||||
transformed.tools = sanitizeClaudeToolSchemas(transformed.tools) as unknown[];
|
||||
}
|
||||
|
||||
// Cloak third-party / blacklisted tool names (e.g. `mixture_of_agents`, or
|
||||
// a large enough set of recognizable snake_case agent tools) that Anthropic
|
||||
// fingerprints and refuses with the same placeholder. The `mcp_*` reserved
|
||||
// namespace is deferred to applyMcpToolNameRewrite below (its bisected
|
||||
// `Mcp_X` form) so the two reverse maps stay disjoint and single-hop.
|
||||
const cloakMap = cloakThirdPartyToolNames(transformed, {
|
||||
skip: (name) => MCP_RESERVED_PREFIX_RE.test(name),
|
||||
});
|
||||
|
||||
const mcpMap = applyMcpToolNameRewrite(transformed);
|
||||
|
||||
const toolNameMap = new Map<string, string>(cloakMap);
|
||||
for (const [alias, original] of mcpMap) {
|
||||
toolNameMap.set(alias, original);
|
||||
}
|
||||
if (toolNameMap.size > 0) {
|
||||
transformed._toolNameMap = toolNameMap;
|
||||
// Non-enumerable: chatCore reads this for response-side tool-name
|
||||
// restoration; the wire body must never carry it (also stripped in execute()).
|
||||
Object.defineProperty(transformed, "_toolNameMap", {
|
||||
value: toolNameMap,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,3 +144,163 @@ export function remapToolNamesInResponse(
|
||||
}
|
||||
|
||||
export { TOOL_RENAME_MAP, REVERSE_MAP };
|
||||
|
||||
/**
|
||||
* Anthropic fingerprints third-party agent harnesses by their tool NAMES on the
|
||||
* first-party Messages API (native Claude OAuth). Two failure modes, both
|
||||
* surfaced as a misleading `400 out of extra usage` placeholder (the SSE stream
|
||||
* is refused, not a real billing event):
|
||||
* 1. Specific blacklisted names (e.g. `mixture_of_agents`) are refused even in
|
||||
* isolation.
|
||||
* 2. A large enough SET of recognizable snake_case agent tool names is
|
||||
* refused collectively, even though each name passes on its own.
|
||||
*
|
||||
* `remapToolNamesInRequest` only normalizes the fixed set of Claude Code tool
|
||||
* names. This generalizes that cloak: any tool name that does not already look
|
||||
* like a genuine Claude Code tool (PascalCase, no separators) is deterministically
|
||||
* aliased — to its Claude Code canonical equivalent when one exists, otherwise to
|
||||
* a PascalCase form of the original. The per-request alias is tracked in the
|
||||
* non-enumerable `_toolNameMap`, so `remapToolNamesInResponse` restores the
|
||||
* caller's original names transparently. Disable with
|
||||
* `CLAUDE_DISABLE_TOOL_NAME_CLOAK=true`.
|
||||
*/
|
||||
const CLAUDE_BUILTIN_TOOL_NAMES = new Set<string>(Object.values(TOOL_RENAME_MAP));
|
||||
|
||||
const HARNESS_CANONICAL_MAP: Record<string, string> = {
|
||||
read_file: "Read",
|
||||
write_file: "Write",
|
||||
search_files: "Grep",
|
||||
grep_search: "Grep",
|
||||
list_directory: "Glob",
|
||||
run_command: "Bash",
|
||||
terminal: "Bash",
|
||||
todo: "TodoWrite",
|
||||
todo_write: "TodoWrite",
|
||||
todo_read: "TodoRead",
|
||||
patch: "Edit",
|
||||
multi_edit: "MultiEdit",
|
||||
};
|
||||
|
||||
function toPascalCaseToolName(name: string): string {
|
||||
const parts = name.split(/[_\s-]+/).filter(Boolean);
|
||||
const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
||||
return pascal || name;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name is left untouched when it already reads as a genuine Claude Code tool:
|
||||
* a PascalCase single token with no separators (Bash, Read, TodoWrite).
|
||||
*/
|
||||
export function needsThirdPartyCloak(name: string): boolean {
|
||||
if (!name) return false;
|
||||
if (CLAUDE_BUILTIN_TOOL_NAMES.has(name)) return false;
|
||||
return /[a-z]/.test(name.charAt(0)) || name.includes("_") || name.includes("-");
|
||||
}
|
||||
|
||||
export interface CloakOptions {
|
||||
/**
|
||||
* Names matching this predicate are left untouched, so a caller that owns a
|
||||
* more specific rewrite (e.g. the CliproxyAPI executor's Anthropic `mcp_*`
|
||||
* reserved-namespace rewrite) keeps authority over them and the two reverse
|
||||
* maps stay disjoint / single-hop.
|
||||
*/
|
||||
skip?: (name: string) => boolean;
|
||||
}
|
||||
|
||||
export function cloakThirdPartyToolNames(
|
||||
body: Record<string, unknown>,
|
||||
options?: CloakOptions
|
||||
): Map<string, string> {
|
||||
// Operator kill-switch (documented in .env.example / ENVIRONMENT.md). Checked
|
||||
// here so every call site — native base.ts AND the CLIProxyAPI executor —
|
||||
// honours it, rather than each caller having to remember to guard.
|
||||
if (process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK === "true") {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
const shouldCloak = (name: string): boolean =>
|
||||
needsThirdPartyCloak(name) && !(options?.skip ? options.skip(name) : false);
|
||||
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
const used = new Set<string>();
|
||||
if (Array.isArray(tools)) {
|
||||
for (const tool of tools) {
|
||||
if (tool && typeof tool.name === "string") used.add(tool.name);
|
||||
}
|
||||
}
|
||||
const existingMap =
|
||||
body._toolNameMap instanceof Map ? (body._toolNameMap as Map<string, string>) : null;
|
||||
if (existingMap) {
|
||||
for (const alias of existingMap.keys()) used.add(alias);
|
||||
}
|
||||
|
||||
// Created lazily so genuine Claude Code traffic (nothing to cloak) does not
|
||||
// get an empty _toolNameMap attached to the request body.
|
||||
let nameMap: Map<string, string> | null = existingMap;
|
||||
const assigned = new Map<string, string>(); // original -> alias
|
||||
|
||||
const aliasFor = (original: string): string => {
|
||||
const existing = assigned.get(original);
|
||||
if (existing) return existing;
|
||||
// Prefer the established Claude Code rename maps (TOOL_RENAME_MAP spreads
|
||||
// EXTRA_TOOL_RENAME_MAP) so the CPA path matches the native path exactly:
|
||||
// subagents->SubDispatch, session_status->CheckStatus, webfetch->WebFetch, …
|
||||
// Then harness-canonical (read_file->Read), then a generic PascalCase.
|
||||
const base =
|
||||
TOOL_RENAME_MAP[original] ?? HARNESS_CANONICAL_MAP[original] ?? toPascalCaseToolName(original);
|
||||
let alias = base;
|
||||
let suffix = 2;
|
||||
while (alias !== original && used.has(alias)) {
|
||||
alias = `${base}${suffix++}`;
|
||||
}
|
||||
used.delete(original);
|
||||
used.add(alias);
|
||||
assigned.set(original, alias);
|
||||
if (!nameMap) nameMap = getRequestToolNameMap(body);
|
||||
nameMap.set(alias, original);
|
||||
return alias;
|
||||
};
|
||||
|
||||
// Non-mutating: clone changed entries rather than rewriting the caller's
|
||||
// objects in place (mirrors applyMcpToolNameRewrite — transformRequest must
|
||||
// not corrupt an input body that may be logged or replayed on fallback).
|
||||
if (Array.isArray(tools)) {
|
||||
body.tools = tools.map((tool) => {
|
||||
if (tool && typeof tool.name === "string" && shouldCloak(tool.name)) {
|
||||
return { ...tool, name: aliasFor(tool.name) };
|
||||
}
|
||||
return tool;
|
||||
});
|
||||
}
|
||||
|
||||
const messages = body.messages as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(messages)) {
|
||||
body.messages = messages.map((message) => {
|
||||
const content = message?.content as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(content)) return message;
|
||||
let changed = false;
|
||||
const newContent = content.map((block) => {
|
||||
if (
|
||||
block?.type === "tool_use" &&
|
||||
typeof block.name === "string" &&
|
||||
shouldCloak(block.name)
|
||||
) {
|
||||
changed = true;
|
||||
return { ...block, name: aliasFor(block.name) };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
return changed ? { ...message, content: newContent } : message;
|
||||
});
|
||||
}
|
||||
|
||||
const toolChoice = body.tool_choice as Record<string, unknown> | undefined;
|
||||
if (
|
||||
toolChoice?.type === "tool" &&
|
||||
typeof toolChoice.name === "string" &&
|
||||
shouldCloak(toolChoice.name)
|
||||
) {
|
||||
body.tool_choice = { ...toolChoice, name: aliasFor(toolChoice.name) };
|
||||
}
|
||||
|
||||
return nameMap ?? new Map<string, string>();
|
||||
}
|
||||
|
||||
@@ -232,3 +232,156 @@ export function injectEmptyReasoningContentForToolCalls(
|
||||
return { ...message, reasoning_content: "" };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic's first-party Messages API strictly validates tool `input_schema`
|
||||
* against JSON Schema draft 2020-12. IDE/SDK agent harnesses that deep-truncate
|
||||
* their schemas emit invalid constructs — most commonly an array keyword
|
||||
* (`enum`, `required`, …) replaced by a placeholder string such as
|
||||
* `"[MaxDepth]"`, or an index-keyed object (`{"0":"a","1":"b"}`) where an array
|
||||
* is expected. Anthropic rejects these with
|
||||
* `tools.N.custom.input_schema: JSON schema is invalid` (surfaced as a
|
||||
* misleading `400 out of extra usage` placeholder when streaming). Non-Anthropic
|
||||
* targets (OpenAI/Codex) tolerate them, which is why the same request succeeds
|
||||
* on a fallback provider. This sanitizer coerces or drops the invalid
|
||||
* constructs so legitimate native-Claude-OAuth traffic is not spuriously
|
||||
* rejected. See Spec E (Claude Code OAuth wire compatibility).
|
||||
*/
|
||||
const SCHEMA_PLACEHOLDER_PATTERN = /^\[(?:MaxDepth|Truncated|Circular|Object|Array)\]$/;
|
||||
const ARRAY_SCHEMA_KEYS = ["enum", "required", "anyOf", "oneOf", "allOf", "prefixItems"];
|
||||
const SCHEMA_ARRAY_OF_SCHEMAS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
|
||||
const SCHEMA_SLOT_KEYS = [
|
||||
"items",
|
||||
"additionalProperties",
|
||||
"propertyNames",
|
||||
"contains",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"unevaluatedProperties",
|
||||
"additionalItems",
|
||||
];
|
||||
|
||||
function coerceIndexedObjectToArray(value: unknown): unknown[] | null {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (isPlainObject(value)) {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 0 && keys.every((key, index) => String(index) === key)) {
|
||||
return keys.map((key) => value[key]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSchemaPlaceholder(value: unknown): boolean {
|
||||
return typeof value === "string" && SCHEMA_PLACEHOLDER_PATTERN.test(value.trim());
|
||||
}
|
||||
|
||||
export function stripInvalidSchemaConstructs(schema: unknown): unknown {
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((entry) => stripInvalidSchemaConstructs(entry));
|
||||
}
|
||||
if (!isPlainObject(schema)) {
|
||||
return isSchemaPlaceholder(schema) ? {} : schema;
|
||||
}
|
||||
|
||||
const result: JsonRecord = {};
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
// Coerce string-encoded numeric constraints (e.g. minimum: "5") to numbers —
|
||||
// Anthropic rejects the string form. Done here so the Claude sanitizer covers
|
||||
// every slot this function recurses into (incl. contains / propertyNames /
|
||||
// additionalItems, which coerceSchemaNumericFields does not visit).
|
||||
if ((NUMERIC_SCHEMA_FIELDS as readonly string[]).includes(key)) {
|
||||
result[key] = coerceNumericString(value);
|
||||
continue;
|
||||
}
|
||||
if (ARRAY_SCHEMA_KEYS.includes(key)) {
|
||||
const array = coerceIndexedObjectToArray(value);
|
||||
if (array === null) continue; // drop invalid non-array keyword (e.g. enum: "[MaxDepth]")
|
||||
result[key] = SCHEMA_ARRAY_OF_SCHEMAS.has(key)
|
||||
? array.map((entry) => stripInvalidSchemaConstructs(entry))
|
||||
: array;
|
||||
continue;
|
||||
}
|
||||
if (SCHEMA_SLOT_KEYS.includes(key)) {
|
||||
// Boolean schemas are valid in JSON Schema (e.g. `additionalProperties: false`
|
||||
// locks down the object); coercing to {} would silently allow extras and
|
||||
// invite the model to hallucinate arguments. Only placeholder strings
|
||||
// (e.g. "[MaxDepth]") get replaced with the permissive {}.
|
||||
if (isPlainObject(value) || Array.isArray(value)) {
|
||||
result[key] = stripInvalidSchemaConstructs(value);
|
||||
} else if (typeof value === "boolean") {
|
||||
result[key] = value;
|
||||
} else if (isSchemaPlaceholder(value)) {
|
||||
result[key] = {};
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key === "const") {
|
||||
if (isSchemaPlaceholder(value)) continue;
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (key === "properties" && isPlainObject(value)) {
|
||||
const properties: JsonRecord = {};
|
||||
for (const [propName, propSchema] of Object.entries(value)) {
|
||||
// Same boolean-preservation rule as SCHEMA_SLOT_KEYS above:
|
||||
// `{ properties: { onlyAdminCanSet: false } }` is a valid permission
|
||||
// gate and must not be silently turned into the permissive {}.
|
||||
if (isPlainObject(propSchema) || Array.isArray(propSchema)) {
|
||||
properties[propName] = stripInvalidSchemaConstructs(propSchema);
|
||||
} else if (typeof propSchema === "boolean") {
|
||||
properties[propName] = propSchema;
|
||||
} else if (isSchemaPlaceholder(propSchema)) {
|
||||
properties[propName] = {};
|
||||
} else {
|
||||
properties[propName] = propSchema;
|
||||
}
|
||||
}
|
||||
result[key] = properties;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(key === "$defs" ||
|
||||
key === "definitions" ||
|
||||
key === "patternProperties" ||
|
||||
key === "dependentSchemas") &&
|
||||
isPlainObject(value)
|
||||
) {
|
||||
const defs: JsonRecord = {};
|
||||
for (const [defName, defSchema] of Object.entries(value)) {
|
||||
defs[defName] = stripInvalidSchemaConstructs(defSchema);
|
||||
}
|
||||
result[key] = defs;
|
||||
continue;
|
||||
}
|
||||
// Placeholders are only coerced to {} in subschema-expecting positions
|
||||
// (handled in the branches above). A placeholder in a scalar annotation
|
||||
// keyword (description / title / pattern / format) must stay scalar —
|
||||
// turning it into {} is itself invalid draft-2020-12 and would re-trigger
|
||||
// the very 400 this sanitizer prevents.
|
||||
result[key] =
|
||||
isPlainObject(value) || Array.isArray(value) ? stripInvalidSchemaConstructs(value) : value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeClaudeToolSchema(schema: unknown): unknown {
|
||||
// stripInvalidSchemaConstructs now also coerces numeric-string constraints, so
|
||||
// it is the single pass for the Claude path. We deliberately do NOT compose
|
||||
// coerceSchemaNumericFields: it strips the valid `default` keyword (Fix #1782,
|
||||
// a translator concern) which on the native / passthrough surface would
|
||||
// silently alter tool schemas that were previously forwarded verbatim.
|
||||
return stripInvalidSchemaConstructs(schema);
|
||||
}
|
||||
|
||||
export function sanitizeClaudeToolSchemas(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map((tool) => {
|
||||
if (!isPlainObject(tool) || tool.input_schema === undefined) return tool;
|
||||
return { ...tool, input_schema: sanitizeClaudeToolSchema(tool.input_schema) };
|
||||
});
|
||||
}
|
||||
|
||||
272
tests/unit/claude-oauth-tool-cloak.test.ts
Normal file
272
tests/unit/claude-oauth-tool-cloak.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Native Claude OAuth tool cloak + schema sanitizer.
|
||||
*
|
||||
* Anthropic's first-party Messages API rejects native-Claude-OAuth requests
|
||||
* that carry (a) invalid tool input_schemas (truncation placeholders / non-array
|
||||
* keywords) or (b) tool names it fingerprints as a third-party agent harness —
|
||||
* both surfaced as a misleading `400 out of extra usage` placeholder. These
|
||||
* tests cover the request-side sanitizer + name cloak; the response side is
|
||||
* reversed via the existing per-request _toolNameMap.
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
cloakThirdPartyToolNames,
|
||||
needsThirdPartyCloak,
|
||||
} from "../../open-sse/services/claudeCodeToolRemapper.ts";
|
||||
import {
|
||||
sanitizeClaudeToolSchema,
|
||||
sanitizeClaudeToolSchemas,
|
||||
} from "../../open-sse/translator/helpers/schemaCoercion.ts";
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
const schemaOf = (tools: unknown, i = 0): AnyRecord =>
|
||||
((tools as AnyRecord[])[i].input_schema as AnyRecord);
|
||||
|
||||
describe("sanitizeClaudeToolSchemas", () => {
|
||||
it("drops a non-array enum placeholder", () => {
|
||||
const tools = [
|
||||
{ name: "x", input_schema: { type: "object", properties: { m: { type: "string", enum: "[MaxDepth]" } } } },
|
||||
];
|
||||
const props = (schemaOf(sanitizeClaudeToolSchemas(tools)).properties as AnyRecord).m as AnyRecord;
|
||||
assert.equal("enum" in props, false);
|
||||
});
|
||||
|
||||
it("coerces an index-keyed object enum into an array", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "object",
|
||||
properties: { a: { type: "string", enum: { "0": "x", "1": "y" } } },
|
||||
}) as AnyRecord;
|
||||
assert.deepEqual(((s.properties as AnyRecord).a as AnyRecord).enum, ["x", "y"]);
|
||||
});
|
||||
|
||||
it("replaces a placeholder property value with a permissive schema", () => {
|
||||
const s = sanitizeClaudeToolSchema({ type: "object", properties: { a: "[MaxDepth]" } }) as AnyRecord;
|
||||
assert.deepEqual((s.properties as AnyRecord).a, {});
|
||||
});
|
||||
|
||||
it("leaves a valid schema intact", () => {
|
||||
const input = { type: "object", properties: { a: { type: "string" } }, required: ["a"] };
|
||||
assert.deepEqual(sanitizeClaudeToolSchema(input), input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloakThirdPartyToolNames", () => {
|
||||
it("aliases a blacklisted name and tracks the reverse map", () => {
|
||||
const body: AnyRecord = { tools: [{ name: "mixture_of_agents" }] };
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "MixtureOfAgents");
|
||||
assert.equal((body._toolNameMap as Map<string, string>).get("MixtureOfAgents"), "mixture_of_agents");
|
||||
});
|
||||
|
||||
it("maps known harness names to Claude Code canonical names", () => {
|
||||
const body: AnyRecord = { tools: [{ name: "read_file" }, { name: "write_file" }, { name: "terminal" }] };
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["Read", "Write", "Bash"]);
|
||||
});
|
||||
|
||||
it("PascalCases unmapped snake_case names", () => {
|
||||
const body: AnyRecord = { tools: [{ name: "honcho_profile" }, { name: "lcm_expand_query" }] };
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["HonchoProfile", "LcmExpandQuery"]);
|
||||
});
|
||||
|
||||
it("leaves genuine Claude Code tool names untouched", () => {
|
||||
const body: AnyRecord = { tools: [{ name: "Bash" }, { name: "Read" }, { name: "TodoWrite" }] };
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["Bash", "Read", "TodoWrite"]);
|
||||
assert.equal((body._toolNameMap as Map<string, string> | undefined)?.size ?? 0, 0);
|
||||
});
|
||||
|
||||
it("dedupes canonical-name collisions", () => {
|
||||
const body: AnyRecord = { tools: [{ name: "search_files" }, { name: "grep_search" }] };
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["Grep", "Grep2"]);
|
||||
});
|
||||
|
||||
it("remaps tool_use blocks in message history consistently", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ name: "mixture_of_agents" }],
|
||||
messages: [{ role: "assistant", content: [{ type: "tool_use", name: "mixture_of_agents" }] }],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
const block = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0];
|
||||
assert.equal(block.name, "MixtureOfAgents");
|
||||
});
|
||||
|
||||
it("does not leak _toolNameMap into the serialized request body", () => {
|
||||
const body: AnyRecord = { tools: [{ name: "mixture_of_agents" }] };
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.equal(JSON.stringify(body).includes("_toolNameMap"), false);
|
||||
});
|
||||
|
||||
it("needsThirdPartyCloak only flags non-Claude-Code names", () => {
|
||||
assert.equal(needsThirdPartyCloak("Bash"), false);
|
||||
assert.equal(needsThirdPartyCloak("TodoWrite"), false);
|
||||
assert.equal(needsThirdPartyCloak("read_file"), true);
|
||||
assert.equal(needsThirdPartyCloak("mixture_of_agents"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeClaudeToolSchemas — boolean schema preservation", () => {
|
||||
it("preserves additionalProperties: false (canonical lock-down)", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
}) as AnyRecord;
|
||||
assert.equal(s.additionalProperties, false);
|
||||
});
|
||||
|
||||
it("preserves additionalProperties: true", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
}) as AnyRecord;
|
||||
assert.equal(s.additionalProperties, true);
|
||||
});
|
||||
|
||||
it("preserves boolean property schemas under properties", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "object",
|
||||
properties: { allowed: true, denied: false },
|
||||
}) as AnyRecord;
|
||||
const props = s.properties as AnyRecord;
|
||||
assert.equal(props.allowed, true);
|
||||
assert.equal(props.denied, false);
|
||||
});
|
||||
|
||||
it("preserves boolean unevaluatedProperties", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "object",
|
||||
unevaluatedProperties: false,
|
||||
}) as AnyRecord;
|
||||
assert.equal(s.unevaluatedProperties, false);
|
||||
});
|
||||
|
||||
it("still replaces a placeholder string in a slot key with permissive {}", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "object",
|
||||
additionalProperties: "[MaxDepth]",
|
||||
}) as AnyRecord;
|
||||
assert.deepEqual(s.additionalProperties, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloakThirdPartyToolNames — defensive null guards", () => {
|
||||
it("tolerates null/undefined entries in tools[]", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [null, { name: "read_file" }, undefined, { name: "Bash" }],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
const names = (body.tools as Array<AnyRecord | null | undefined>).map((t) => t?.name);
|
||||
assert.deepEqual(names, [undefined, "Read", undefined, "Bash"]);
|
||||
});
|
||||
|
||||
it("tolerates null/undefined entries in messages[]", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ name: "read_file" }],
|
||||
messages: [
|
||||
null,
|
||||
{ role: "assistant", content: [{ type: "tool_use", name: "read_file" }] },
|
||||
undefined,
|
||||
],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
const block = (
|
||||
(body.messages as Array<AnyRecord>)[1].content as Array<AnyRecord>
|
||||
)[0];
|
||||
assert.equal(block.name, "Read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloakThirdPartyToolNames — non-mutating + skip option", () => {
|
||||
it("does not mutate the caller's input tool objects", () => {
|
||||
const original: AnyRecord = { name: "read_file" };
|
||||
const body: AnyRecord = { tools: [original] };
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.equal(original.name, "read_file"); // input object untouched
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "Read"); // body.tools reassigned with a clone
|
||||
});
|
||||
|
||||
it("does not mutate the caller's input message blocks", () => {
|
||||
const block: AnyRecord = { type: "tool_use", name: "read_file" };
|
||||
const body: AnyRecord = {
|
||||
tools: [{ name: "read_file" }],
|
||||
messages: [{ role: "assistant", content: [block] }],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.equal(block.name, "read_file"); // input block untouched
|
||||
const out = ((body.messages as AnyRecord[])[0].content as AnyRecord[])[0];
|
||||
assert.equal(out.name, "Read");
|
||||
});
|
||||
|
||||
it("leaves names matched by the skip predicate untouched", () => {
|
||||
const body: AnyRecord = { tools: [{ name: "mcp_call" }, { name: "read_file" }] };
|
||||
cloakThirdPartyToolNames(body, { skip: (n) => n.startsWith("mcp_") });
|
||||
assert.deepEqual((body.tools as AnyRecord[]).map((t) => t.name), ["mcp_call", "Read"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("review fixes — schema sanitizer scalar / default / numeric", () => {
|
||||
it("keeps a placeholder in a scalar annotation keyword as a scalar (not {})", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "object",
|
||||
description: "[Object]",
|
||||
properties: { a: { type: "string", description: "[Truncated]" } },
|
||||
}) as AnyRecord;
|
||||
assert.equal(s.description, "[Object]");
|
||||
assert.equal(((s.properties as AnyRecord).a as AnyRecord).description, "[Truncated]");
|
||||
});
|
||||
|
||||
it("preserves the valid `default` keyword on the Claude path", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "object",
|
||||
properties: { mode: { type: "string", default: "replace" }, all: { type: "boolean", default: false } },
|
||||
}) as AnyRecord;
|
||||
const p = s.properties as AnyRecord;
|
||||
assert.equal((p.mode as AnyRecord).default, "replace");
|
||||
assert.equal((p.all as AnyRecord).default, false);
|
||||
});
|
||||
|
||||
it("coerces numeric-string constraints inside contains (not only items)", () => {
|
||||
const s = sanitizeClaudeToolSchema({
|
||||
type: "array",
|
||||
contains: { type: "object", properties: { n: { type: "integer", minimum: "5" } } },
|
||||
}) as AnyRecord;
|
||||
const n = ((s.contains as AnyRecord).properties as AnyRecord).n as AnyRecord;
|
||||
assert.equal(n.minimum, 5);
|
||||
});
|
||||
|
||||
it("still coerces a placeholder to {} in a real subschema slot", () => {
|
||||
const s = sanitizeClaudeToolSchema({ type: "object", additionalProperties: "[MaxDepth]" }) as AnyRecord;
|
||||
assert.deepEqual(s.additionalProperties, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("review fixes — established aliases + kill-switch", () => {
|
||||
it("uses the established Claude Code aliases on the cloak path", () => {
|
||||
const body: AnyRecord = {
|
||||
tools: [{ name: "subagents" }, { name: "session_status" }, { name: "webfetch" }, { name: "todowrite" }],
|
||||
};
|
||||
cloakThirdPartyToolNames(body);
|
||||
assert.deepEqual(
|
||||
(body.tools as AnyRecord[]).map((t) => t.name),
|
||||
["SubDispatch", "CheckStatus", "WebFetch", "TodoWrite"]
|
||||
);
|
||||
});
|
||||
|
||||
it("CLAUDE_DISABLE_TOOL_NAME_CLOAK=true disables the cloak at the function level", () => {
|
||||
const prev = process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK;
|
||||
process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK = "true";
|
||||
try {
|
||||
const body: AnyRecord = { tools: [{ name: "mixture_of_agents" }] };
|
||||
const map = cloakThirdPartyToolNames(body);
|
||||
assert.equal((body.tools as AnyRecord[])[0].name, "mixture_of_agents");
|
||||
assert.equal(map.size, 0);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK;
|
||||
else process.env.CLAUDE_DISABLE_TOOL_NAME_CLOAK = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -457,14 +457,18 @@ describe("CliproxyapiExecutor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite non-mcp_ tool names", () => {
|
||||
it("cloaks non-mcp third-party tool names to PascalCase (fingerprint defense, PR #2943)", () => {
|
||||
const exec = new CliproxyapiExecutor();
|
||||
const body = anthropicBodyWithTools([
|
||||
{ name: "my_tool", description: "My tool", input_schema: {} },
|
||||
]);
|
||||
const result = exec.transformRequest("claude-opus-4-7", body, true, {});
|
||||
const toolName = (result.tools as Array<{ name: string }>)[0].name;
|
||||
assert.equal(toolName, "my_tool");
|
||||
// Non-Claude-Code tool names are now cloaked (my_tool -> MyTool) so Anthropic
|
||||
// does not fingerprint the third-party harness; restored on the response via
|
||||
// the non-enumerable _toolNameMap.
|
||||
assert.equal(toolName, "MyTool");
|
||||
assert.equal((result._toolNameMap as Map<string, string>).get("MyTool"), "my_tool");
|
||||
});
|
||||
|
||||
it("rewrites mcp_* tool_use names in assistant message history", () => {
|
||||
|
||||
Reference in New Issue
Block a user