Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 9 additions & 1 deletion open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 33 additions & 2 deletions open-sse/executors/cliproxyapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
}
}

Expand Down
160 changes: 160 additions & 0 deletions open-sse/services/claudeCodeToolRemapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Comment on lines +225 to +229

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential runtime TypeErrors if the tools array contains null or undefined values, add a defensive check to ensure tool is defined before accessing its properties.

Suggested change
if (Array.isArray(tools)) {
for (const tool of tools) {
if (typeof tool.name === "string") used.add(tool.name);
}
}
if (Array.isArray(tools)) {
for (const tool of tools) {
if (tool && typeof tool.name === "string") used.add(tool.name);
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks — addressed in 3b2d075. Booleans preserved verbatim (placeholders still get the permissive {}), null-guards added on tools[]/messages[], and CLAUDE_DISABLE_TOOL_NAME_CLOAK is now documented. Regression tests cover all of the above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks (3b2d075).

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;
});
}
Comment on lines +266 to +273

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similarly, add a defensive check to ensure tool is defined before accessing tool.name here.

Suggested change
if (Array.isArray(tools)) {
for (const tool of tools) {
if (typeof tool.name === "string" && needsThirdPartyCloak(tool.name)) {
tool.name = aliasFor(tool.name);
}
}
}
if (Array.isArray(tools)) {
for (const tool of tools) {
if (tool && typeof tool.name === "string" && needsThirdPartyCloak(tool.name)) {
tool.name = aliasFor(tool.name);
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks — addressed in 3b2d075. Booleans preserved verbatim (placeholders still get the permissive {}), null-guards added on tools[]/messages[], and CLAUDE_DISABLE_TOOL_NAME_CLOAK is now documented. Regression tests cover all of the above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks (3b2d075).


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;
});
}
Comment on lines +275 to +294

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a defensive check to ensure message is defined before accessing message.content to prevent potential runtime TypeErrors if the messages array contains null or undefined values.

Suggested change
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const message of messages) {
const content = message.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block?.type === "tool_use" && typeof block.name === "string" && needsThirdPartyCloak(block.name)) {
block.name = aliasFor(block.name);
}
}
}
}
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const message of messages) {
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block?.type === "tool_use" && typeof block.name === "string" && needsThirdPartyCloak(block.name)) {
block.name = aliasFor(block.name);
}
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks — addressed in 3b2d075. Booleans preserved verbatim (placeholders still get the permissive {}), null-guards added on tools[]/messages[], and CLAUDE_DISABLE_TOOL_NAME_CLOAK is now documented. Regression tests cover all of the above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks. Also added the \ optional-chain on the same loop (3b2d075).


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>();
}
Loading