mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(claude): apply tool cloak + schema sanitize on the CLIProxyAPI executor path
The native Claude OAuth guard in executors/base.ts is bypassed when `upstream_proxy_config.mode = cliproxyapi` routes the request through the CliproxyAPI executor — it has its own execute()/transformRequest() and never reaches BaseExecutor.execute(), so the cloak/sanitizer never ran for that (common) deployment. Wire the same guards into CliproxyapiExecutor.transformRequest (Anthropic-shape branch), composing with the existing bisected `mcp_*` reserved-namespace rewrite: - sanitizeClaudeToolSchemas() on transformed.tools. - cloakThirdPartyToolNames() with skip = mcp-reserved, so applyMcpToolNameRewrite keeps authority over `mcp_*` (its bisected `Mcp_X` form) and the two reverse maps stay disjoint / single-hop. Both merge into the non-enumerable _toolNameMap the response stream already uses to restore the caller's names. cloakThirdPartyToolNames is now non-mutating (clones changed entries) to respect transformRequest's no-input-mutation contract, and takes an optional `skip` predicate. Verified end-to-end through the live CPA path: a real ~100-tool harness payload that returned the "out of extra usage" placeholder now returns 200 with original tool names restored on the response stream; `mcp_*` tools and genuine PascalCase Claude Code tools are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,22 @@ export function needsThirdPartyCloak(name: string): boolean {
|
||||
return /[a-z]/.test(name.charAt(0)) || name.includes("_") || name.includes("-");
|
||||
}
|
||||
|
||||
export function cloakThirdPartyToolNames(body: Record<string, unknown>): Map<string, string> {
|
||||
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> {
|
||||
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>();
|
||||
@@ -234,34 +249,46 @@ export function cloakThirdPartyToolNames(body: Record<string, unknown>): Map<str
|
||||
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)) {
|
||||
for (const tool of tools) {
|
||||
if (tool && typeof tool.name === "string" && needsThirdPartyCloak(tool.name)) {
|
||||
tool.name = aliasFor(tool.name);
|
||||
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)) {
|
||||
for (const message of messages) {
|
||||
body.messages = messages.map((message) => {
|
||||
const content = message?.content as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const block of content) {
|
||||
if (!Array.isArray(content)) return message;
|
||||
let changed = false;
|
||||
const newContent = content.map((block) => {
|
||||
if (
|
||||
block?.type === "tool_use" &&
|
||||
typeof block.name === "string" &&
|
||||
needsThirdPartyCloak(block.name)
|
||||
shouldCloak(block.name)
|
||||
) {
|
||||
block.name = aliasFor(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" && needsThirdPartyCloak(toolChoice.name)) {
|
||||
toolChoice.name = aliasFor(toolChoice.name);
|
||||
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>();
|
||||
|
||||
@@ -179,3 +179,31 @@ describe("cloakThirdPartyToolNames — defensive null guards", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user