From 1b45e104f245f3da080d48e70be5a42eda7a5ca1 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:48:35 +0200 Subject: [PATCH 1/2] feat(claude-cli): serve a tool catalog through the capture-only bridge The Claude Code CLI provider answered every turn tools-disabled, so a request that carried a tool catalog came back as text only. Arming the capture bridge the CodeBuddy rows already use closes that gap: the CLI is launched with --mcp-config plus the exact --allowedTools list, advertises the request's catalog from the isolated stdio server, and a captured call is returned to the client, which keeps approval, sandboxing and execution. The bridge and its MCP server move from src/adapters/codebuddy/ to src/adapters/coding-agent/. Both harnesses speak the same contract (verified against CodeBuddy Code and Claude Code 2.1.281: the init frame reports the server as connected and renders calls as mcp____), so the family modules now supply only their own arguments, child environment and MCP server path. The private compiled-binary entrypoint follows the move (__codebuddy-mcp -> __coding-agent-mcp), bridge failures read family-neutrally, and the error a client sees still names its provider. A catalog turn stages the same private prompt file as before, now with the bridge directive folded in, and requests without a catalog keep the previous text-only argument shape. --- .../src/content/docs/guides/providers.md | 18 +- scripts/test-layout/layout.json | 1 + src/adapters/claude-cli/adapter.ts | 53 +++- src/adapters/codebuddy/adapter.ts | 48 +--- .../{codebuddy => coding-agent}/mcp-server.ts | 36 +-- .../tool-bridge.ts | 158 ++++++++---- src/adapters/coding-agent/turn.ts | 8 +- src/cli/index.ts | 6 +- src/providers/registry/entries-extended.ts | 7 +- structure/adapters/registry.md | 8 +- structure/providers-and-adapters.md | 5 +- tests/fixtures/test-layout-expected.json | 1 + .../providers/claude-cli-tool-bridge.test.ts | 234 ++++++++++++++++++ tests/providers/codebuddy-mcp-server.test.ts | 18 +- .../codebuddy-tool-bridge-turn.test.ts | 28 +-- tests/providers/codebuddy-tool-bridge.test.ts | 100 ++++---- 16 files changed, 528 insertions(+), 201 deletions(-) rename src/adapters/{codebuddy => coding-agent}/mcp-server.ts (80%) rename src/adapters/{codebuddy => coding-agent}/tool-bridge.ts (73%) create mode 100644 tests/providers/claude-cli-tool-bridge.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a0663d011c5..0de988d7afe 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -951,15 +951,25 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn: variable (a `claude` already pointed at this proxy therefore cannot loop back into it), telemetry, feedback and the auto-updater disabled, and `--tools ""`, `--strict-mcp-config` plus `--setting-sources ""`. The harness loads no CLAUDE.md, skill, hook, plugin or MCP server from the - machine and can neither read, write, exec nor browse. No session is persisted between turns. + machine and can neither read, write, exec nor browse; the one MCP server a turn can reach is the + bridge's own capture server, and only when the request advertises a catalog. No session is + persisted between turns. - **System prompt:** the caller's system and developer prompts replace the Claude Code preset (`--system-prompt-file`), so the turn answers the client's contract rather than the harness persona. The folded prompt is staged in a private per-turn file (mode `0600`) and passed by path, because process arguments are world-readable through process listing; a request that carries neither a system nor a developer prompt gets an empty file, which replaces the preset with nothing. -- **Tool ownership:** v1 is text and reasoning only, exactly like the CodeBuddy and Qoder presets: - with no tool channel, approval, sandboxing and execution stay with the client. The shared - capture-only tool bridge is the documented follow-up. +- **Tool Ownership and the Tool Bridge:** the CLI is always spawned with `--tools ""` and + `--strict-mcp-config`, so it has no built-in or user-configured tools of its own. When a request + carries a Codex tool catalog, the provider arms the same capture-only MCP bridge the CodeBuddy + presets use: the validated catalog and MCP config are written to a private temp dir, the CLI is + launched with `--mcp-config` and an exact `--allowedTools` list, and the `system/init` frame must + report exactly that bridge server as connected or the turn fails closed. The bridge advertises the + Codex tools and captures proposed calls but never executes anything: a completed tool-call batch is + returned to the client with the request's wire names (at most 16 calls per assistant message), the + process tree is terminated at `message_stop`, and approval, sandboxing and execution stay with the + external client. Tool results come back as the next request's input, and the conversation + continues. Requests without a catalog keep the plain text-and-reasoning shape. - **Destination:** the canonical row names `https://api.anthropic.com` because that is where the subscription's traffic lands. OpenCodex never sends that request itself, and overriding the base URL fails closed rather than handing the turn to another environment. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b1dbc20861f..bab43a20488 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -363,6 +363,7 @@ "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", "claude-cli-adapter.test.ts": "providers", + "claude-cli-tool-bridge.test.ts": "providers", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts index 28d302f0836..2857b31272d 100644 --- a/src/adapters/claude-cli/adapter.ts +++ b/src/adapters/claude-cli/adapter.ts @@ -1,16 +1,29 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; import type { AdapterRequest, ProviderAdapter } from "../base"; import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; -import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn"; +import { + baseScopedEnv, + runCodingAgentTurn, + type CodingAgentDeps, +} from "../coding-agent/turn"; +import { + buildCodingAgentToolBridge, + codingAgentToolBridgeInput, + CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT, + type CodingAgentToolBridge, +} from "../coding-agent/tool-bridge"; import { CLAUDE_CLI_PROFILES, type ClaudeCliProfile } from "./profiles"; export type { SpawnFn } from "../coding-agent/turn"; export type ClaudeCliAdapterDeps = CodingAgentDeps; +const CLAUDE_CLI_MCP_SERVER_PATH = fileURLToPath(new URL("../coding-agent/mcp-server.ts", import.meta.url)); + /** * Quiet the CLI's own outbound traffic. * @@ -58,8 +71,11 @@ export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Reco * Build the headless Claude Code arguments for one turn. * * Tool ownership stays with the client: `--tools ""` disables every built-in tool and - * `--strict-mcp-config` (with no `--mcp-config`) keeps user, project and plugin MCP servers out, so - * the harness can neither read, write, exec nor browse the operator's tree. `--setting-sources ""` + * `--strict-mcp-config` keeps user, project and plugin MCP servers out, so the harness can neither + * read, write, exec nor browse the operator's tree. A request that carries a tool catalog adds the + * capture-only bridge's own `--mcp-config` (with exact `--allowedTools` names) on top — that + * isolated server advertises the catalog and never answers a call, so the client still executes + * nothing (see `../coding-agent/tool-bridge.ts` / `../coding-agent/mcp-server.ts`). `--setting-sources ""` * stops the CLI from loading CLAUDE.md, skills, hooks, plugins and output styles into a proxied * turn, which is what makes the request deterministic instead of dependent on the host's setup. * @@ -148,11 +164,13 @@ export function withClaudeLoginHint(emit: (event: AdapterEvent) => void): (event } /** - * Create the Claude Code CLI adapter: one headless, tools-disabled, sessionless turn per request. + * Create the Claude Code CLI adapter: one headless, sessionless turn per request. * * As with CodeBuddy and Qoder, `runTurn` owns the turn and the HTTP path is disabled — the CLI * performs the transport, and OpenCodex contributes the request projection, the stream mapping and - * the process lifecycle. + * the process lifecycle. Built-in tools stay disabled for every turn; a request that carries a + * tool catalog is served through the shared capture-only bridge, which advertises the catalog to + * the model and returns captured calls to the client, which keeps approval and execution. */ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: ClaudeCliAdapterDeps = {}): ProviderAdapter { return { @@ -177,16 +195,36 @@ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: Claude }); return; } + let toolBridge: CodingAgentToolBridge; + try { + toolBridge = buildCodingAgentToolBridge(parsed); + } catch (err) { + emit({ + type: "error", + message: `Invalid Claude Code tool catalog: ${err instanceof Error ? err.message : String(err)}`, + status: 400, + errorType: "invalid_request_error", + code: "tool_catalog_invalid", + retryable: false, + }); + return; + } + const bridgeInput = codingAgentToolBridgeInput(toolBridge, CLAUDE_CLI_MCP_SERVER_PATH); // argv is world-readable via process listing, so the folded system+developer prompt is staged // in a private per-turn file and passed by path. The file is written even when the caller // sends no prompt at all: the flag has to be present either way, and an empty replacement is - // what keeps the harness preset out of the turn. + // what keeps the harness preset out of the turn. A catalog turn appends the bridge directive + // to the same file, so the model is told which tools it may propose and who executes them. + const system = buildSystemPrompt(parsed); + const systemParts: string[] = []; + if (system) systemParts.push(system); + if (toolBridge.tools.length > 0) systemParts.push(CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT); let promptDir: string | undefined; let promptFile: string | undefined; try { promptDir = await mkdtemp(join(tmpdir(), "ocx-claude-cli-prompt-")); promptFile = join(promptDir, "system-prompt.txt"); - await writeFile(promptFile, buildSystemPrompt(parsed) ?? "", { encoding: "utf8", mode: 0o600, flag: "wx" }); + await writeFile(promptFile, systemParts.join("\n\n"), { encoding: "utf8", mode: 0o600, flag: "wx" }); } catch { if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); emit({ @@ -206,6 +244,7 @@ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: Claude parsed, incoming, emit: withClaudeLoginHint(emit), + ...(bridgeInput ? { toolBridge: bridgeInput } : {}), buildArgs: (profile, req, prov) => buildArgs(profile as ClaudeCliProfile, req, prov, promptFile), buildEnv: (profile, apiKey) => buildChildEnv(profile as ClaudeCliProfile, apiKey), deps, diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index 705393ec3de..ac000cd7160 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -10,35 +10,21 @@ import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, - type CodingAgentToolBridgeInput, type SpawnFn, } from "../coding-agent/turn"; import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "./profiles"; import { guardCodeBuddyScaffolding } from "./scaffold-guard"; import { - buildCodeBuddyToolBridge, - CODEBUDDY_MCP_SERVER_NAME, - CODEBUDDY_TOOL_LIMITS, - type CodeBuddyToolBridge, -} from "./tool-bridge"; + buildCodingAgentToolBridge, + codingAgentToolBridgeInput, + CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT, + type CodingAgentToolBridge, +} from "../coding-agent/tool-bridge"; export type { SpawnFn } from "../coding-agent/turn"; export type CodeBuddyAdapterDeps = CodingAgentDeps; -const CODEBUDDY_MCP_SERVER_PATH = fileURLToPath(new URL("./mcp-server.ts", import.meta.url)); - -/** - * Tool-bridge contract lines appended to the system prompt when a catalog is advertised. - * Mirrors the capture-only design: the model may propose calls, the external Codex client - * alone performs approval, sandboxing, and execution. - */ -const TOOL_BRIDGE_SYSTEM_PROMPT = [ - "Your built-in tools and user-configured MCP servers are disabled.", - "When an isolated opencodex MCP catalog is present, you may call only those listed tools.", - "That MCP process captures call intent only; it never executes a tool. The external Codex client performs approval, sandboxing, and execution.", - "Do not claim that you executed commands, inspected files, or changed the workspace.", - "Tool-call and tool-result records in the conversation history are authoritative historical records from the external client. Use returned results, but never execute historical calls yourself.", -].join("\n"); +const CODEBUDDY_MCP_SERVER_PATH = fileURLToPath(new URL("../coding-agent/mcp-server.ts", import.meta.url)); /** * Build the scoped child-process environment for a CodeBuddy turn (§六/§十四). @@ -64,8 +50,9 @@ export function buildChildEnv(profile: CodeBuddyProfile, apiKey: string): Record * (with no `--mcp-config`) blocks MCP tools, so the CLI can neither read, write, exec, nor browse the * workspace. `-y/--dangerously-skip-permissions` is deliberately NOT passed, so any operation that * would require authorization is blocked. The turn is a single text/reasoning pass over stream-json - * unless the request carries a tool catalog: then the capture-only MCP bridge advertises exactly - * that catalog (see `tool-bridge.ts` / `mcp-server.ts`) and the CLI still executes nothing itself. + * unless the request carries a tool catalog: then the shared capture-only MCP bridge advertises + * exactly that catalog (see `../coding-agent/tool-bridge.ts` / `../coding-agent/mcp-server.ts`) and + * the CLI still executes nothing itself. */ export function buildArgs( profile: CodeBuddyProfile, @@ -109,9 +96,9 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu }, async runTurn(parsed, incoming, emit): Promise { - let toolBridge: CodeBuddyToolBridge; + let toolBridge: CodingAgentToolBridge; try { - toolBridge = buildCodeBuddyToolBridge(parsed); + toolBridge = buildCodingAgentToolBridge(parsed); } catch (err) { emit({ type: "error", @@ -123,23 +110,14 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu }); return; } - const bridgeInput: CodingAgentToolBridgeInput | undefined = toolBridge.tools.length > 0 - ? { - serverName: CODEBUDDY_MCP_SERVER_NAME, - serverModulePath: CODEBUDDY_MCP_SERVER_PATH, - tools: toolBridge.tools, - emittedNameMap: toolBridge.emittedNameMap, - maxTurnToolCalls: CODEBUDDY_TOOL_LIMITS.maxTurnToolCalls, - requireToolCall: toolBridge.requireToolCall, - } - : undefined; + const bridgeInput = codingAgentToolBridgeInput(toolBridge, CODEBUDDY_MCP_SERVER_PATH); // argv is world-readable via process listing, so the folded system+developer prompt — // plus the tool-bridge directive when a catalog is advertised — is staged in a // private temp file and passed by path instead of embedded in the arguments. const system = buildSystemPrompt(parsed); const systemParts: string[] = []; if (system) systemParts.push(system); - if (toolBridge.tools.length > 0) systemParts.push(TOOL_BRIDGE_SYSTEM_PROMPT); + if (toolBridge.tools.length > 0) systemParts.push(CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT); const staged = systemParts.length > 0 ? systemParts.join("\n\n") : undefined; let promptDir: string | undefined; let promptFile: string | undefined; diff --git a/src/adapters/codebuddy/mcp-server.ts b/src/adapters/coding-agent/mcp-server.ts similarity index 80% rename from src/adapters/codebuddy/mcp-server.ts rename to src/adapters/coding-agent/mcp-server.ts index ae471849c9c..75f711ea57b 100644 --- a/src/adapters/codebuddy/mcp-server.ts +++ b/src/adapters/coding-agent/mcp-server.ts @@ -1,10 +1,14 @@ /** - * Isolated MCP catalog used by the CodeBuddy adapter. + * Isolated MCP catalog shared by the coding-agent CLI adapters. * * This process advertises the current Codex tool schemas but deliberately never - * executes a call. The parent adapter captures CodeBuddy's completed `tool_use` + * executes a call. The parent adapter captures the CLI's completed `tool_use` * frame, terminates this process tree, and returns the call to the Codex host, * where the normal approval and sandbox boundary remains authoritative. + * + * Both harnesses this repository drives were verified against the same contract: + * each reports the server as `connected` in its `system/init` frame and renders a + * call as `mcp____` (CodeBuddy Code; Claude Code 2.1.281). */ import { open } from "node:fs/promises"; @@ -14,7 +18,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { CODEBUDDY_TOOL_LIMITS } from "./tool-bridge"; +import { CODING_AGENT_TOOL_LIMITS } from "./tool-bridge"; interface ToolDefinition { name: string; @@ -53,21 +57,21 @@ async function readCatalogBounded(path: string): Promise { try { const before = await handle.stat(); if (!before.isFile()) throw new Error("tool catalog must be a regular file"); - if (before.size > CODEBUDDY_TOOL_LIMITS.maxCatalogBytes) { + if (before.size > CODING_AGENT_TOOL_LIMITS.maxCatalogBytes) { throw new Error("tool catalog is too large"); } // Read at most limit + 1 from the already-open descriptor. The extra byte // distinguishes an exact-limit file from a file that grew after fstat, // without ever allocating or retaining an attacker-sized input. - const bytes = Buffer.allocUnsafe(CODEBUDDY_TOOL_LIMITS.maxCatalogBytes + 1); + const bytes = Buffer.allocUnsafe(CODING_AGENT_TOOL_LIMITS.maxCatalogBytes + 1); let offset = 0; while (offset < bytes.length) { const result = await handle.read(bytes, offset, bytes.length - offset, offset); if (result.bytesRead === 0) break; offset += result.bytesRead; } - if (offset > CODEBUDDY_TOOL_LIMITS.maxCatalogBytes) { + if (offset > CODING_AGENT_TOOL_LIMITS.maxCatalogBytes) { throw new Error("tool catalog is too large"); } @@ -90,7 +94,7 @@ async function readCatalogBounded(path: string): Promise { function assertBoundedSchema(schema: Record): void { if (schema.type !== "object") throw new Error("tool input schema must have object type"); - if (utf8Bytes(JSON.stringify(schema)) > CODEBUDDY_TOOL_LIMITS.maxSchemaBytes) { + if (utf8Bytes(JSON.stringify(schema)) > CODING_AGENT_TOOL_LIMITS.maxSchemaBytes) { throw new Error("tool input schema is too large"); } @@ -99,10 +103,10 @@ function assertBoundedSchema(schema: Record): void { while (pending.length > 0) { const current = pending.pop()!; nodes += 1; - if (nodes > CODEBUDDY_TOOL_LIMITS.maxSchemaNodes) { + if (nodes > CODING_AGENT_TOOL_LIMITS.maxSchemaNodes) { throw new Error("tool input schema has too many nodes"); } - if (current.depth > CODEBUDDY_TOOL_LIMITS.maxSchemaDepth) { + if (current.depth > CODING_AGENT_TOOL_LIMITS.maxSchemaDepth) { throw new Error("tool input schema is too deeply nested"); } if (Array.isArray(current.value)) { @@ -119,7 +123,7 @@ async function loadTools(path: string): Promise { const bytes = await readCatalogBounded(path); const parsed: unknown = JSON.parse(bytes.toString("utf8")); if (!Array.isArray(parsed)) throw new Error("tool catalog must be an array"); - if (parsed.length > CODEBUDDY_TOOL_LIMITS.maxTools) { + if (parsed.length > CODING_AGENT_TOOL_LIMITS.maxTools) { throw new Error("tool catalog contains too many definitions"); } @@ -129,12 +133,12 @@ async function loadTools(path: string): Promise { !isRecord(value) || typeof value.name !== "string" || !MCP_TOOL_NAME_PATTERN.test(value.name) - || utf8Bytes(value.name) > CODEBUDDY_TOOL_LIMITS.maxNameBytes + || utf8Bytes(value.name) > CODING_AGENT_TOOL_LIMITS.maxNameBytes || typeof value.description !== "string" || value.description.length < 1 || hasUnpairedSurrogate(value.description) || INVALID_DESCRIPTION_CONTROL_PATTERN.test(value.description) - || utf8Bytes(value.description) > CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + || utf8Bytes(value.description) > CODING_AGENT_TOOL_LIMITS.maxDescriptionBytes || !isRecord(value.inputSchema) ) { throw new Error("tool catalog contains an invalid definition"); @@ -147,14 +151,14 @@ async function loadTools(path: string): Promise { description: value.description, inputSchema: value.inputSchema, }; - if (utf8Bytes(JSON.stringify(definition)) > CODEBUDDY_TOOL_LIMITS.maxToolBytes) { + if (utf8Bytes(JSON.stringify(definition)) > CODING_AGENT_TOOL_LIMITS.maxToolBytes) { throw new Error("tool catalog contains an oversized definition"); } return definition; }); } -export async function runCodeBuddyMcpServer(catalogPath: string): Promise { +export async function runCodingAgentMcpServer(catalogPath: string): Promise { if (!catalogPath) throw new Error("missing tool catalog"); // The pinned SDK does not detect stdin EOF itself. The capture server must exit when // the parent terminates its CLI, including after a captured message_stop. @@ -165,7 +169,7 @@ export async function runCodeBuddyMcpServer(catalogPath: string): Promise const tools = await loadTools(catalogPath); const advertisedNames = new Set(tools.map(tool => tool.name)); const server = new Server( - { name: "opencodex-codebuddy-capture", version: "1.0.0" }, + { name: "opencodex-capture", version: "1.0.0" }, { capabilities: { tools: {} } }, ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); @@ -177,4 +181,4 @@ export async function runCodeBuddyMcpServer(catalogPath: string): Promise await server.connect(new StdioServerTransport()); } -if (import.meta.main) await runCodeBuddyMcpServer(process.argv[2] ?? ""); +if (import.meta.main) await runCodingAgentMcpServer(process.argv[2] ?? ""); diff --git a/src/adapters/codebuddy/tool-bridge.ts b/src/adapters/coding-agent/tool-bridge.ts similarity index 73% rename from src/adapters/codebuddy/tool-bridge.ts rename to src/adapters/coding-agent/tool-bridge.ts index 815121caaa5..af1646538d3 100644 --- a/src/adapters/codebuddy/tool-bridge.ts +++ b/src/adapters/coding-agent/tool-bridge.ts @@ -1,3 +1,16 @@ +/** + * Capture-only tool bridge for the official coding-agent CLIs. + * + * The nested CLI never executes a tool. This module turns the request's tool catalog into MCP + * definitions the CLI can advertise, renames them into the `mcp____` shape the + * harness renders, and maps captured calls back to the request's wire names. `turn.ts` stages the + * catalog and the MCP config, `mcp-server.ts` serves the capture-only stdio server, and the parent + * adapter ends the leg at `message_stop` — approval, sandboxing and execution stay with the client. + * + * The naming rules are the harness's rather than a provider's: every supported CLI renders MCP + * tools as `mcp____`, so an alias stays inside 40 characters and keeps the complete + * rendered name comfortably below the common 64-character function-name limit. + */ import { createHash } from "node:crypto"; import { namespacedToolName, @@ -7,14 +20,31 @@ import { type OcxToolChoice, } from "../../types"; import { stripResponsesOnlyEncryptedMarker } from "../responses-tool-schema"; +import type { CodingAgentToolBridgeInput } from "./turn"; + +export const CODING_AGENT_MCP_SERVER_NAME = "opencodex"; +export const CODING_AGENT_MCP_TOOL_PREFIX = `mcp__${CODING_AGENT_MCP_SERVER_NAME}__`; -export const CODEBUDDY_MCP_SERVER_NAME = "opencodex"; -export const CODEBUDDY_MCP_TOOL_PREFIX = `mcp__${CODEBUDDY_MCP_SERVER_NAME}__`; +/** + * Contract lines appended to the system prompt when a catalog is advertised. + * + * The capture-only design is what makes the phrasing load-bearing: the model may propose calls, + * and the external client alone performs approval, sandboxing and execution. The model has to be + * told that a historical tool record is a record rather than an invitation to act, because the + * nested CLI cannot see that boundary itself. + */ +export const CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT = [ + "Your built-in tools and user-configured MCP servers are disabled.", + "When an isolated opencodex MCP catalog is present, you may call only those listed tools.", + "That MCP process captures call intent only; it never executes a tool. The external Codex client performs approval, sandboxing, and execution.", + "Do not claim that you executed commands, inspected files, or changed the workspace.", + "Tool-call and tool-result records in the conversation history are authoritative historical records from the external client. Use returned results, but never execute historical calls yourself.", +].join("\n"); // These caps protect both the request path and the isolated MCP process. They sit // below the adapter's 4 MiB total prompt cap so a maximal tool catalog cannot // crowd the transcript and system prompt out of the request budget. -export const CODEBUDDY_TOOL_LIMITS = Object.freeze({ +export const CODING_AGENT_TOOL_LIMITS = Object.freeze({ maxTools: 128, // Captured tool_use blocks accepted in a single assistant turn. Kimi emits // parallel calls as sibling content blocks of one assistant message, all @@ -33,11 +63,11 @@ export const CODEBUDDY_TOOL_LIMITS = Object.freeze({ maxPatternBytes: 8 * 1024, }); -// CodeBuddy renders MCP tools as `mcp____`. Keep the complete +// Every supported CLI renders MCP tools as `mcp____`. Keep the complete // rendered name comfortably below the common 64-character function-name limit. -const MAX_CODEBUDDY_TOOL_ALIAS_CHARS = 40; -const CODEBUDDY_TOOL_ALIAS_HASH_CHARS = 16; -const CODEBUDDY_TOOL_ALIAS_PATTERN = /^[A-Za-z0-9_-]+$/; +const MAX_TOOL_ALIAS_CHARS = 40; +const TOOL_ALIAS_HASH_CHARS = 16; +const TOOL_ALIAS_PATTERN = /^[A-Za-z0-9_-]+$/; const INVALID_TOOL_NAME_PATTERN = /[\s\u0000-\u001f\u007f]/u; const INVALID_DESCRIPTION_CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u; const JSON_SCHEMA_TYPES = new Set(["array", "boolean", "integer", "null", "number", "object", "string"]); @@ -93,14 +123,14 @@ const STRING_KEYWORDS = [ const BOOLEAN_KEYWORDS = ["deprecated", "nullable", "readOnly", "uniqueItems", "writeOnly"] as const; const textEncoder = new TextEncoder(); -export interface CodeBuddyMcpToolDefinition { +export interface CodingAgentMcpToolDefinition { name: string; description: string; inputSchema: Record; } -export interface CodeBuddyToolBridge { - tools: CodeBuddyMcpToolDefinition[]; +export interface CodingAgentToolBridge { + tools: CodingAgentMcpToolDefinition[]; /** Exact nested-CLI-emitted MCP name -> Responses wire name. */ emittedNameMap: Map; requireToolCall: boolean; @@ -166,9 +196,9 @@ function invalidJson(reason: string): never { * is fixed rather than attacker-controlled. */ function cloneBoundedJson(value: unknown, depth: number, state: JsonCloneState): unknown { - if (depth > CODEBUDDY_TOOL_LIMITS.maxSchemaDepth) invalidJson("nesting is too deep"); + if (depth > CODING_AGENT_TOOL_LIMITS.maxSchemaDepth) invalidJson("nesting is too deep"); state.nodes += 1; - if (state.nodes > CODEBUDDY_TOOL_LIMITS.maxSchemaNodes) invalidJson("node count is too large"); + if (state.nodes > CODING_AGENT_TOOL_LIMITS.maxSchemaNodes) invalidJson("node count is too large"); if (value === null || typeof value === "boolean" || typeof value === "string") { if (typeof value === "string" && hasUnpairedSurrogate(value)) invalidJson("text contains an unpaired surrogate"); @@ -186,7 +216,7 @@ function cloneBoundedJson(value: unknown, depth: number, state: JsonCloneState): try { if (Array.isArray(value)) { if (Object.getPrototypeOf(value) !== Array.prototype) invalidJson("arrays must use the built-in prototype"); - if (value.length > CODEBUDDY_TOOL_LIMITS.maxSchemaNodes) invalidJson("array length is too large"); + if (value.length > CODING_AGENT_TOOL_LIMITS.maxSchemaNodes) invalidJson("array length is too large"); const keys = Reflect.ownKeys(value); for (const key of keys) { @@ -259,7 +289,7 @@ function validateSchemaMap(value: unknown, keyword: string, validatePatterns = f } function validatePattern(value: unknown, keyword = "pattern"): void { - if (typeof value !== "string" || utf8Bytes(value) > CODEBUDDY_TOOL_LIMITS.maxPatternBytes) { + if (typeof value !== "string" || utf8Bytes(value) > CODING_AGENT_TOOL_LIMITS.maxPatternBytes) { invalidSchema(`${keyword} must be a bounded regular-expression string`); } try { @@ -404,8 +434,8 @@ function normalizeInputSchema(parameters: unknown): Record { if (!isRecord(parameters)) invalidSchema("the root must be an object schema"); const cloned = cloneBoundedJson(parameters, 0, { active: new WeakSet(), nodes: 0 }); if (!isRecord(cloned)) invalidSchema("the root must be an object schema"); - if (serializedBytes(cloned) > CODEBUDDY_TOOL_LIMITS.maxSchemaBytes) { - throw new Error(`schema exceeds ${CODEBUDDY_TOOL_LIMITS.maxSchemaBytes} bytes`); + if (serializedBytes(cloned) > CODING_AGENT_TOOL_LIMITS.maxSchemaBytes) { + throw new Error(`schema exceeds ${CODING_AGENT_TOOL_LIMITS.maxSchemaBytes} bytes`); } validateSchema(cloned); if (Object.hasOwn(cloned, "type") && cloned.type !== "object") { @@ -415,8 +445,8 @@ function normalizeInputSchema(parameters: unknown): Record { const stripped = stripResponsesOnlyEncryptedMarker(cloned); if (!isRecord(stripped)) invalidSchema("the root must remain an object schema"); if (!Object.hasOwn(stripped, "type")) stripped.type = "object"; - if (serializedBytes(stripped) > CODEBUDDY_TOOL_LIMITS.maxSchemaBytes) { - throw new Error(`schema exceeds ${CODEBUDDY_TOOL_LIMITS.maxSchemaBytes} bytes`); + if (serializedBytes(stripped) > CODING_AGENT_TOOL_LIMITS.maxSchemaBytes) { + throw new Error(`schema exceeds ${CODING_AGENT_TOOL_LIMITS.maxSchemaBytes} bytes`); } return stripped; } @@ -425,12 +455,12 @@ function shortHash(value: string, salt = 0): string { return createHash("sha256") .update(salt === 0 ? value : `${value}\0${salt}`) .digest("hex") - .slice(0, CODEBUDDY_TOOL_ALIAS_HASH_CHARS); + .slice(0, TOOL_ALIAS_HASH_CHARS); } -function directCodeBuddyAlias(wireName: string): string | undefined { - return CODEBUDDY_TOOL_ALIAS_PATTERN.test(wireName) - && wireName.length <= MAX_CODEBUDDY_TOOL_ALIAS_CHARS +function directToolAlias(wireName: string): string | undefined { + return TOOL_ALIAS_PATTERN.test(wireName) + && wireName.length <= MAX_TOOL_ALIAS_CHARS ? wireName : undefined; } @@ -439,39 +469,39 @@ function directCodeBuddyAlias(wireName: string): string | undefined { * Produce a deterministic MCP-safe alias while retaining a readable prefix. * `used` closes both normalization and truncated-hash collision domains. */ -export function codeBuddyToolAlias(wireName: string, used = new Set()): string { - const direct = directCodeBuddyAlias(wireName); +export function codingAgentToolAlias(wireName: string, used = new Set()): string { + const direct = directToolAlias(wireName); if (direct && !used.has(direct)) { used.add(direct); return direct; } const cleaned = wireName.replace(/[^A-Za-z0-9_-]/g, "_"); - const maxBaseChars = MAX_CODEBUDDY_TOOL_ALIAS_CHARS - CODEBUDDY_TOOL_ALIAS_HASH_CHARS - 1; + const maxBaseChars = MAX_TOOL_ALIAS_CHARS - TOOL_ALIAS_HASH_CHARS - 1; const base = (cleaned || "tool").slice(0, maxBaseChars); - for (let salt = 0; salt <= CODEBUDDY_TOOL_LIMITS.maxTools; salt++) { + for (let salt = 0; salt <= CODING_AGENT_TOOL_LIMITS.maxTools; salt++) { const candidate = `${base}_${shortHash(wireName, salt)}`; if (!used.has(candidate)) { used.add(candidate); return candidate; } } - throw new Error("CodeBuddy could not allocate a collision-free tool alias."); + throw new Error("The tool alias allocator could not find a collision-free name."); } /** Reserve direct names before hashing and sort the rest so request ordering cannot change aliases. */ -function codeBuddyToolAliases(wireNames: readonly string[]): Map { +function codingAgentToolAliases(wireNames: readonly string[]): Map { const aliases = new Map(); const used = new Set(); for (const wireName of wireNames) { - const direct = directCodeBuddyAlias(wireName); + const direct = directToolAlias(wireName); if (direct) { aliases.set(wireName, direct); used.add(direct); } } const hashedNames = wireNames.filter(wireName => !aliases.has(wireName)).sort(); - for (const wireName of hashedNames) aliases.set(wireName, codeBuddyToolAlias(wireName, used)); + for (const wireName of hashedNames) aliases.set(wireName, codingAgentToolAlias(wireName, used)); return aliases; } @@ -490,28 +520,28 @@ function validateToolNamePart(value: unknown): value is string { } function prepareTool(tool: OcxTool, index: number, seenWireNames: Set): PreparedTool { - if (!tool || typeof tool !== "object") throw new Error(`CodeBuddy tool ${index + 1} is not an object.`); + if (!tool || typeof tool !== "object") throw new Error(`Tool ${index + 1} is not an object.`); if (!validateToolNamePart(tool.name) || ( tool.namespace !== undefined && !validateToolNamePart(tool.namespace) )) { - throw new Error(`CodeBuddy tool ${index + 1} has an invalid name or namespace.`); + throw new Error(`Tool ${index + 1} has an invalid name or namespace.`); } const wireName = namespacedToolName(tool.namespace, tool.name); - if (utf8Bytes(wireName) > CODEBUDDY_TOOL_LIMITS.maxNameBytes) { - throw new Error(`CodeBuddy tool ${index + 1} name exceeds ${CODEBUDDY_TOOL_LIMITS.maxNameBytes} bytes.`); + if (utf8Bytes(wireName) > CODING_AGENT_TOOL_LIMITS.maxNameBytes) { + throw new Error(`Tool ${index + 1} name exceeds ${CODING_AGENT_TOOL_LIMITS.maxNameBytes} bytes.`); } if (seenWireNames.has(wireName)) { - throw new Error(`CodeBuddy tool catalog contains a duplicate wire name: ${wireName}.`); + throw new Error(`The tool catalog contains a duplicate wire name: ${wireName}.`); } seenWireNames.add(wireName); if (typeof tool.description !== "string" || hasUnpairedSurrogate(tool.description) || INVALID_DESCRIPTION_CONTROL_PATTERN.test(tool.description)) { - throw new Error(`CodeBuddy tool ${index + 1} has an invalid description.`); + throw new Error(`Tool ${index + 1} has an invalid description.`); } const description = tool.description || `Tool: ${wireName}`; - if (utf8Bytes(description) > CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes) { - throw new Error(`CodeBuddy tool ${index + 1} description exceeds ${CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes} bytes.`); + if (utf8Bytes(description) > CODING_AGENT_TOOL_LIMITS.maxDescriptionBytes) { + throw new Error(`Tool ${index + 1} description exceeds ${CODING_AGENT_TOOL_LIMITS.maxDescriptionBytes} bytes.`); } let inputSchema: Record; @@ -519,14 +549,14 @@ function prepareTool(tool: OcxTool, index: number, seenWireNames: Set): inputSchema = normalizeInputSchema(tool.parameters ?? {}); } catch (error) { const detail = error instanceof Error ? error.message : "unknown schema error"; - throw new Error(`CodeBuddy tool ${index + 1} has an invalid input schema: ${detail}.`); + throw new Error(`Tool ${index + 1} has an invalid input schema: ${detail}.`); } return { source: tool, wireName, description, inputSchema }; } -function buildToolBridge(parsed: OcxParsedRequest): CodeBuddyToolBridge { +function buildToolBridge(parsed: OcxParsedRequest): CodingAgentToolBridge { const allTools = parsed.context.tools ?? []; - if (!Array.isArray(allTools)) throw new Error("CodeBuddy tool catalog must be an array."); + if (!Array.isArray(allTools)) throw new Error("The tool catalog must be an array."); const choice = parsed.options.toolChoice; const requireToolCall = requiresToolCall(choice); @@ -554,36 +584,36 @@ function buildToolBridge(parsed: OcxParsedRequest): CodeBuddyToolBridge { .map((tool, index) => ({ index, tool })) .filter(({ tool }) => allows(tool)); if (requireToolCall && selected.length === 0) { - throw new Error("CodeBuddy tool_choice requires a tool, but no matching tool is available."); + throw new Error("tool_choice requires a tool, but no matching tool is available."); } - if (selected.length > CODEBUDDY_TOOL_LIMITS.maxTools) { - throw new Error(`CodeBuddy tool catalog exceeds the ${CODEBUDDY_TOOL_LIMITS.maxTools}-tool limit.`); + if (selected.length > CODING_AGENT_TOOL_LIMITS.maxTools) { + throw new Error(`The tool catalog exceeds the ${CODING_AGENT_TOOL_LIMITS.maxTools}-tool limit.`); } const seenWireNames = new Set(); const prepared = selected.map(({ index, tool }) => prepareTool(tool, index, seenWireNames)); - const aliases = codeBuddyToolAliases(prepared.map(tool => tool.wireName)); - const definitions = prepared.map((tool, index): CodeBuddyMcpToolDefinition => { + const aliases = codingAgentToolAliases(prepared.map(tool => tool.wireName)); + const definitions = prepared.map((tool, index): CodingAgentMcpToolDefinition => { const definition = { name: aliases.get(tool.wireName)!, description: tool.description, inputSchema: tool.inputSchema, }; - if (serializedBytes(definition) > CODEBUDDY_TOOL_LIMITS.maxToolBytes) { - throw new Error(`CodeBuddy tool ${index + 1} definition exceeds ${CODEBUDDY_TOOL_LIMITS.maxToolBytes} bytes.`); + if (serializedBytes(definition) > CODING_AGENT_TOOL_LIMITS.maxToolBytes) { + throw new Error(`Tool ${index + 1} definition exceeds ${CODING_AGENT_TOOL_LIMITS.maxToolBytes} bytes.`); } return definition; }); - if (serializedBytes(definitions) > CODEBUDDY_TOOL_LIMITS.maxCatalogBytes) { - throw new Error(`CodeBuddy tool catalog exceeds ${CODEBUDDY_TOOL_LIMITS.maxCatalogBytes} bytes.`); + if (serializedBytes(definitions) > CODING_AGENT_TOOL_LIMITS.maxCatalogBytes) { + throw new Error(`The tool catalog exceeds ${CODING_AGENT_TOOL_LIMITS.maxCatalogBytes} bytes.`); } const emittedNameMap = new Map(); const tools = prepared.map((preparedTool, index) => { const definition = definitions[index]; - const emittedName = `${CODEBUDDY_MCP_TOOL_PREFIX}${definition.name}`; + const emittedName = `${CODING_AGENT_MCP_TOOL_PREFIX}${definition.name}`; if (emittedNameMap.has(emittedName)) { - throw new Error("CodeBuddy tool catalog contains a colliding emitted alias."); + throw new Error("The tool catalog contains a colliding emitted alias."); } emittedNameMap.set(emittedName, preparedTool.wireName); return definition; @@ -592,6 +622,28 @@ function buildToolBridge(parsed: OcxParsedRequest): CodeBuddyToolBridge { return { tools, emittedNameMap, requireToolCall }; } -export function buildCodeBuddyToolBridge(parsed: OcxParsedRequest): CodeBuddyToolBridge { +export function buildCodingAgentToolBridge(parsed: OcxParsedRequest): CodingAgentToolBridge { return buildToolBridge(parsed); } + +/** + * Translate a built bridge into one turn's bridge input, or `undefined` when the request + * advertised no catalog — the shape every family adapter hands to `runCodingAgentTurn`. + * + * The family module contributes only its own MCP server path; the advertised name, the catalog and + * the per-turn call cap come from this shared module, so two adapters cannot drift apart on them. + */ +export function codingAgentToolBridgeInput( + bridge: CodingAgentToolBridge, + serverModulePath: string, +): CodingAgentToolBridgeInput | undefined { + if (bridge.tools.length === 0) return undefined; + return { + serverName: CODING_AGENT_MCP_SERVER_NAME, + serverModulePath, + tools: bridge.tools, + emittedNameMap: bridge.emittedNameMap, + maxTurnToolCalls: CODING_AGENT_TOOL_LIMITS.maxTurnToolCalls, + requireToolCall: bridge.requireToolCall, + }; +} diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 67014960621..7b15ece2277 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -140,8 +140,8 @@ export interface CodingAgentToolBridgeInput { requireToolCall?: boolean; } -export function codeBuddyMcpInvocation(serverModulePath: string, catalogPath: string, standalone = isStandaloneBinary()): string[] { - return standalone ? ["__codebuddy-mcp", catalogPath] : [serverModulePath, catalogPath]; +export function codingAgentMcpInvocation(serverModulePath: string, catalogPath: string, standalone = isStandaloneBinary()): string[] { + return standalone ? ["__coding-agent-mcp", catalogPath] : [serverModulePath, catalogPath]; } /** @@ -237,7 +237,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise(() => {}); diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index c4327df4f08..2b9e2ce2e95 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1444,8 +1444,9 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ // path is Anthropic's own harness rather than a replayed Claude Code identity against the // Messages API. `baseUrl` is the destination the subscription's traffic reaches; OpenCodex // never sends it. Fails closed if the row's base URL is overridden. - // v1 runs tools-disabled (`--tools ""`, no `--mcp-config`) so the client keeps tool ownership: - // text/reasoning only until the shared capture-only tool bridge lands. Requires the CLI: + // The CLI always runs tools-disabled (`--tools ""`); a request that carries a tool catalog arms + // the shared capture-only MCP bridge (`src/adapters/coding-agent/tool-bridge.ts`), which lends + // the CLI exactly that catalog and returns captured calls to the client for execution. Requires: // `npm i -g @anthropic-ai/claude-code`, plus a signed-in session (`claude` -> /login). // GOVERNANCE: whether a subscription login may be driven through a proxy for a third-party // agent is Anthropic's call rather than OpenCodex's — flagged for maintainer review, as with @@ -1486,6 +1487,6 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ reasoningEfforts: ANTHROPIC_REASONING_EFFORTS, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, - note: "Runs Claude subscription traffic through Anthropic's own harness: the official Claude Code CLI headlessly (`claude -p`), one turn per request. OpenCodex stores no Claude token, reads none and injects none — the CLI signs in and bills the account itself, which is why this row is keyless and an API key saved here never reaches the harness (use `anthropic-apikey` for key billing). The sign-in is the one of the user this proxy runs as, so every request served through this row — by any client of this proxy — spends that same account; OpenCodex neither pools nor multiplexes Claude sign-ins. Requires the CLI (`npm i -g @anthropic-ai/claude-code`) and a signed-in session (`claude` -> /login). v1 disables CLI tools (--tools \"\", --strict-mcp-config) so the client retains tool ownership: text/reasoning only for now. Subscription routing authorization flagged for maintainer review.", + note: "Runs Claude subscription traffic through Anthropic's own harness: the official Claude Code CLI headlessly (`claude -p`), one turn per request. OpenCodex stores no Claude token, reads none and injects none — the CLI signs in and bills the account itself, which is why this row is keyless and an API key saved here never reaches the harness (use `anthropic-apikey` for key billing). The sign-in is the one of the user this proxy runs as, so every request served through this row — by any client of this proxy — spends that same account; OpenCodex neither pools nor multiplexes Claude sign-ins. Requires the CLI (`npm i -g @anthropic-ai/claude-code`) and a signed-in session (`claude` -> /login). The CLI always runs tools-disabled (--tools \"\"); a capture-only MCP bridge surfaces the request's Codex tool catalog as capturable calls, with approval and execution kept by the client. Subscription routing authorization flagged for maintainer review.", }, ]; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 71a01735d4e..f11b1b23674 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -32,7 +32,8 @@ Some adapters share another adapter's routed-tool semantics while retaining inde - `mimo-free` inherits the `openai-chat` contract. - `claude-cli` inherits the `codebuddy` contract. Claude Code speaks the same stream-json protocol this repository already parses for CodeBuddy and Qoder, so the wire is inherited and the - family module (`src/adapters/claude-cli/`) supplies only its own arguments and child environment. + family module (`src/adapters/claude-cli/`) supplies only its own arguments, child environment and + MCP server path. That profile is the first credentialless one: it omits `tokenEnv`, the CLI reads the operator's own Claude Code sign-in, and the turn neither requires nor injects an API key. The proxy-safety controls are set per invocation, through CLI arguments and the child environment, and @@ -40,6 +41,11 @@ Some adapters share another adapter's routed-tool semantics while retaining inde `--setting-sources ""`, `--no-session-persistence`, no permission bypass, a folded prompt staged in a 0600 per-turn file and passed as `--system-prompt-file` rather than as a world-readable argument, and a child environment that carries no inherited `ANTHROPIC_*` value. + A request that carries a tool catalog arms the shared capture-only bridge + (`src/adapters/coding-agent/tool-bridge.ts` and `mcp-server.ts`, the modules the CodeBuddy adapter + uses too): the family module contributes the server path, and the `--mcp-config`/`--allowedTools` + pair arrives from the shared turn. Built-in tools stay disabled either way, and a catalog the + bridge refuses fails the request with `tool_catalog_invalid`. Its registry row is `authKind: "key"` with `keyOptional: true`, NOT `local`: the turn leaves the machine for `api.anthropic.com`, and `local` (Ollama, vLLM, LM Studio) is the classification for traffic that never does. `keyOptional` is the existing exemption from key enforcement, and key diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 3144cc41d3f..7974103e896 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -13,8 +13,9 @@ cancels unparsed authorization/mint failures, including mint429, without reflect The capture-only bridge in `src/adapters/coding-agent/turn.ts` reports staging failures with the fixed `tool_bridge_setup_failed` error, never an OS error carrying private file paths. Failure prevents CLI spawn and settles the bridge's private directory; the CodeBuddy adapter -also settles its prompt-file directory. Catalog and MCP-config write failures cover both owners. -In a compiled executable, the bridge launches the private `__codebuddy-mcp` CLI entrypoint; +and the Claude Code adapter also settle their prompt-file directories. Catalog and MCP-config write +failures cover both owners. +In a compiled executable, the bridge launches the private `__coding-agent-mcp` CLI entrypoint; source execution launches the MCP module with Bun. Both paths advertise only the request's isolated catalog and leave tool execution to the external client. Qoder appends the folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 10b04d90c0c..5645c998e4b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -189,6 +189,7 @@ "claude-auth-mode.test.ts": "claude-integration", "claude-authmode-migration.test.ts": "claude-integration", "claude-cli-adapter.test.ts": "providers", + "claude-cli-tool-bridge.test.ts": "providers", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", diff --git a/tests/providers/claude-cli-tool-bridge.test.ts b/tests/providers/claude-cli-tool-bridge.test.ts new file mode 100644 index 00000000000..b5b29944547 --- /dev/null +++ b/tests/providers/claude-cli-tool-bridge.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { existsSync, readFileSync } from "node:fs"; +import { Readable, Writable } from "node:stream"; +import { dirname } from "node:path"; +import type { ChildProcess } from "node:child_process"; +import { createClaudeCliAdapter, type SpawnFn } from "../../src/adapters/claude-cli/adapter"; +import { CLAUDE_CLI_PROFILE, clearClaudeCliBinaryCache } from "../../src/adapters/claude-cli/profiles"; +import { CODING_AGENT_MCP_SERVER_NAME, buildCodingAgentToolBridge } from "../../src/adapters/coding-agent/tool-bridge"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const enc = new TextEncoder(); + +beforeEach(() => clearClaudeCliBinaryCache()); + +interface FakeChild extends EventEmitter { + stdout: Readable; + stderr: Readable; + stdin: Writable; + killed: boolean; + exitCode: number | null; + kill: (signal?: string) => boolean; +} + +function fakeChild(stdout: Uint8Array[]): FakeChild { + const child = new EventEmitter() as FakeChild; + child.stdout = Readable.from(stdout); + child.stderr = Readable.from([]); + child.stdin = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + setTimeout(() => { child.exitCode = 0; child.emit("close", 0); }, 3); + return child; +} + +function tool(name: string): OcxTool { + return { + name, + description: `Tool ${name}`, + parameters: { type: "object", properties: { path: { type: "string" } } }, + }; +} + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "claude-cli", + baseUrl: CLAUDE_CLI_PROFILE.canonicalBaseUrl, + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + ...overrides, + } as OcxProviderConfig; +} + +function parsed(overrides: Partial = {}): OcxParsedRequest { + return { + modelId: "claude-sonnet-5", + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "read a file", timestamp: 0 }] }, + ...overrides, + } as OcxParsedRequest; +} + +async function run(adapter: ReturnType, p: OcxParsedRequest): Promise { + const events: AdapterEvent[] = []; + await adapter.runTurn!(p, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + return events; +} + +function frameLines(frames: unknown[]): Uint8Array[] { + return frames.map(frame => enc.encode(JSON.stringify(frame) + "\n")); +} + +/** + * The frame shapes Claude Code 2.1.281 emits for a capture-only turn: the bridge server is reported + * as connected in `system/init`, the call renders as `mcp____`, and the process parks + * on the never-answering handler after `message_stop` instead of delivering a `result` frame. + */ +const INIT_OK = { type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected", source: "dynamic" }] }; +const INIT_NO_SERVER = { type: "system", subtype: "init", mcp_servers: [] }; + +function toolUseStart(name: string, id = "toolu_1"): unknown { + return { type: "stream_event", event: { type: "content_block_start", content_block: { type: "tool_use", id, name } } }; +} +function inputJsonDelta(part: string): unknown { + return { type: "stream_event", event: { type: "content_block_delta", delta: { type: "input_json_delta", partial_json: part } } }; +} +const BLOCK_STOP = { type: "stream_event", event: { type: "content_block_stop" } }; +const MESSAGE_STOP = { type: "stream_event", event: { type: "message_stop" } }; + +describe("claude-cli capture-only tool bridge", () => { + test("advertises an isolated MCP server, captures the call, renames it, and ends the leg at message_stop", async () => { + const p = parsed({ context: { systemPrompt: ["Be terse."], messages: [{ role: "user", content: "read a file", timestamp: 0 }], tools: [tool("read_file")] } }); + const bridge = buildCodingAgentToolBridge(p); + const cliName = [...bridge.emittedNameMap.keys()][0]!; + const wireName = bridge.emittedNameMap.get(cliName)!; + expect(cliName.startsWith(`mcp__${CODING_AGENT_MCP_SERVER_NAME}__`)).toBe(true); + + let child: FakeChild | undefined; + let seenArgs: readonly string[] = []; + let mcpConfigPath = ""; + let mcpServer: { type: string; command: string; args: string[] } | undefined; + let advertisedCatalog: Array<{ name: string }> = []; + const spawn: SpawnFn = (_cmd, args) => { + seenArgs = args; + const index = args.indexOf("--mcp-config"); + if (index >= 0) mcpConfigPath = args[index + 1] ?? ""; + // The staging directory is private and removed once the turn settles, so the files are read + // while the turn is still running. + const config = JSON.parse(readFileSync(mcpConfigPath, "utf8")) as { + mcpServers: Record; + }; + mcpServer = config.mcpServers[CODING_AGENT_MCP_SERVER_NAME]; + advertisedCatalog = JSON.parse(readFileSync(mcpServer!.args[1]!, "utf8")) as Array<{ name: string }>; + child = fakeChild(frameLines([ + INIT_OK, + toolUseStart(cliName), + inputJsonDelta('{"path":'), + inputJsonDelta('"README.md"}'), + BLOCK_STOP, + MESSAGE_STOP, + ])); + return child as unknown as ChildProcess; + }; + const adapter = createClaudeCliAdapter(provider(), { spawn, which: () => "/opt/homebrew/bin/claude" }); + const events = await run(adapter, p); + + // Built-in tools stay off and no permission bypass is requested: the isolated catalog is the + // only capability this turn can reach. + expect(seenArgs[seenArgs.indexOf("--tools") + 1]).toBe(""); + expect(seenArgs).toContain("--strict-mcp-config"); + const allowedIndex = seenArgs.indexOf("--allowedTools"); + expect(seenArgs[allowedIndex + 1]).toBe(cliName); + expect(mcpConfigPath).toContain("ocx-coding-agent-tools-"); + + // The MCP config is the shape Claude Code accepts (verified against 2.1.281): a stdio server + // running the shared capture module with the private catalog as its only argument. + expect(mcpServer?.type).toBe("stdio"); + expect(mcpServer?.command).toBe(process.execPath); + expect(mcpServer?.args[0]!.endsWith("src/adapters/coding-agent/mcp-server.ts")).toBe(true); + expect(mcpServer?.args[1]!.endsWith("catalog.json")).toBe(true); + // The catalog carries the bare alias; the harness renders it as `mcp____`. + const prefix = `mcp__${CODING_AGENT_MCP_SERVER_NAME}__`; + expect(advertisedCatalog.map(entry => entry.name)) + .toEqual([...bridge.emittedNameMap.keys()].map(name => name.slice(prefix.length))); + + expect(events.map(event => event.type)).toEqual([ + "tool_call_start", + "tool_call_delta", + "tool_call_delta", + "tool_call_end", + "done", + ]); + expect(events[0]).toMatchObject({ type: "tool_call_start", name: wireName }); + expect(events[4]).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + expect(child?.killed).toBe(true); + // Both private directories are gone once the turn settled. + expect(existsSync(dirname(mcpConfigPath))).toBe(false); + }); + + test("stages the bridge directive with the caller's prompt, out of argv", async () => { + const secret = "private-system-instruction"; + const p = parsed({ context: { systemPrompt: [secret], messages: [{ role: "user", content: "read a file", timestamp: 0 }], tools: [tool("read_file")] } }); + let staged = ""; + const spawn: SpawnFn = (_cmd, args) => { + expect(args).not.toContain(secret); + staged = readFileSync(args[args.indexOf("--system-prompt-file") + 1]!, "utf8"); + return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + }; + const adapter = createClaudeCliAdapter(provider(), { spawn, which: () => "/opt/homebrew/bin/claude", killGraceMs: 20 }); + await run(adapter, p); + + expect(staged).toContain(secret); + expect(staged).toContain("That MCP process captures call intent only; it never executes a tool."); + }); + + test("a request without a catalog keeps the text-only arg shape and stages no directive", async () => { + let seenArgs: readonly string[] = []; + let staged = ""; + const spawn: SpawnFn = (_cmd, args) => { + seenArgs = args; + staged = readFileSync(args[args.indexOf("--system-prompt-file") + 1]!, "utf8"); + return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + }; + const adapter = createClaudeCliAdapter(provider(), { spawn, which: () => "/opt/homebrew/bin/claude", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + + expect(seenArgs).not.toContain("--mcp-config"); + expect(seenArgs).not.toContain("--allowedTools"); + expect(staged).toBe(""); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("tool_choice none keeps the text-only arg shape", async () => { + let seenArgs: readonly string[] = []; + const spawn: SpawnFn = (_cmd, args) => { + seenArgs = args; + return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + }; + const adapter = createClaudeCliAdapter(provider(), { spawn, which: () => "/opt/homebrew/bin/claude", killGraceMs: 20 }); + await run(adapter, parsed({ + options: { toolChoice: "none" }, + context: { messages: [{ role: "user", content: "read a file", timestamp: 0 }], tools: [tool("read_file")] }, + })); + expect(seenArgs).not.toContain("--mcp-config"); + expect(seenArgs).not.toContain("--allowedTools"); + }); + + test("an invalid catalog fails closed as a request error before any spawn", async () => { + let spawns = 0; + const spawn: SpawnFn = () => { spawns++; return fakeChild([]) as unknown as ChildProcess; }; + const adapter = createClaudeCliAdapter(provider(), { spawn, which: () => "/opt/homebrew/bin/claude" }); + const events = await run(adapter, parsed({ + context: { messages: [{ role: "user", content: "go", timestamp: 0 }], tools: [tool("bad name")] }, + })); + expect(spawns).toBe(0); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 400, code: "tool_catalog_invalid", retryable: false }); + expect(String((events[0] as { message: string }).message)).toContain("Invalid Claude Code tool catalog"); + }); + + test("an init frame that does not report the capture server fails closed", async () => { + const spawn: SpawnFn = () => fakeChild(frameLines([ + INIT_NO_SERVER, + { type: "result", subtype: "success", is_error: false }, + ])) as unknown as ChildProcess; + const adapter = createClaudeCliAdapter(provider(), { spawn, which: () => "/opt/homebrew/bin/claude", killGraceMs: 20 }); + const events = await run(adapter, parsed({ + context: { messages: [{ role: "user", content: "read a file", timestamp: 0 }], tools: [tool("read_file")] }, + })); + expect(events[0]).toMatchObject({ type: "error", code: "tool_bridge_init_mismatch", retryable: false }); + }); +}); diff --git a/tests/providers/codebuddy-mcp-server.test.ts b/tests/providers/codebuddy-mcp-server.test.ts index 3deac6e24a4..cd8de497db5 100644 --- a/tests/providers/codebuddy-mcp-server.test.ts +++ b/tests/providers/codebuddy-mcp-server.test.ts @@ -5,8 +5,8 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { CODEBUDDY_TOOL_LIMITS } from "../../src/adapters/codebuddy/tool-bridge"; -import { codeBuddyMcpInvocation } from "../../src/adapters/coding-agent/turn"; +import { CODING_AGENT_TOOL_LIMITS } from "../../src/adapters/coding-agent/tool-bridge"; +import { codingAgentMcpInvocation } from "../../src/adapters/coding-agent/turn"; const tempDirs: string[] = []; const serverPath = join( @@ -15,7 +15,7 @@ const serverPath = join( "..", "src", "adapters", - "codebuddy", + "coding-agent", "mcp-server.ts", ); @@ -74,14 +74,14 @@ describe("CodeBuddy capture-only MCP server", () => { expect(version.stdout.toString()).toContain("opencodex"); const catalogPath = join(dir, "catalog.json"); writeFileSync(catalogPath, JSON.stringify([definition("lookup")]), { mode: 0o600 }); - const probe = Bun.spawn({ cmd: [binary, ...codeBuddyMcpInvocation(serverPath, catalogPath, true)], stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + const probe = Bun.spawn({ cmd: [binary, ...codingAgentMcpInvocation(serverPath, catalogPath, true)], stdin: "pipe", stdout: "pipe", stderr: "pipe" }); probe.stdin.end(); const probeError = await new Response(probe.stderr).text(); const probeOutput = await new Response(probe.stdout).text(); expect({ exit: await probe.exited, stderr: probeError, stdout: probeOutput }).toEqual({ exit: 0, stderr: "", stdout: "" }); const transport = new StdioClientTransport({ command: binary, - args: codeBuddyMcpInvocation(serverPath, catalogPath, true), + args: codingAgentMcpInvocation(serverPath, catalogPath, true), stderr: "pipe", }); const client = new Client({ name: "compiled-codebuddy-test", version: "1.0.0" }); @@ -156,14 +156,14 @@ describe("CodeBuddy capture-only MCP server", () => { test("reads at most the catalog limit plus one byte", async () => { const stderr = await rejectedCatalog( - " ".repeat(CODEBUDDY_TOOL_LIMITS.maxCatalogBytes + 1), + " ".repeat(CODING_AGENT_TOOL_LIMITS.maxCatalogBytes + 1), ); expect(stderr).toContain("tool catalog is too large"); }); test("revalidates count, unique names, text, and schema boundaries in the helper", async () => { let deeplyNested: Record = { type: "object" }; - for (let depth = 0; depth <= CODEBUDDY_TOOL_LIMITS.maxSchemaDepth; depth++) { + for (let depth = 0; depth <= CODING_AGENT_TOOL_LIMITS.maxSchemaDepth; depth++) { deeplyNested = { type: "object", nested: deeplyNested }; } @@ -171,7 +171,7 @@ describe("CodeBuddy capture-only MCP server", () => { { expected: "too many definitions", value: Array.from( - { length: CODEBUDDY_TOOL_LIMITS.maxTools + 1 }, + { length: CODING_AGENT_TOOL_LIMITS.maxTools + 1 }, (_, index) => definition(`tool_${index}`), ), }, @@ -186,7 +186,7 @@ describe("CodeBuddy capture-only MCP server", () => { { expected: "invalid definition", value: [definition("description", { - description: "d".repeat(CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + 1), + description: "d".repeat(CODING_AGENT_TOOL_LIMITS.maxDescriptionBytes + 1), })], }, { diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index 99e2a241c45..c88066bfe77 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -7,7 +7,7 @@ import { basename, dirname, join } from "node:path"; import { Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; import { createCodeBuddyAdapter, type SpawnFn } from "../../src/adapters/codebuddy/adapter"; -import { buildCodeBuddyToolBridge } from "../../src/adapters/codebuddy/tool-bridge"; +import { buildCodingAgentToolBridge } from "../../src/adapters/coding-agent/tool-bridge"; import { CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../../src/adapters/codebuddy/profiles"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -182,7 +182,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("advertises the catalog, captures the call, renames it, and ends the leg at message_stop", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; const wireName = bridge.emittedNameMap.get(cliName)!; @@ -243,7 +243,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a tool-bridge turn reports the partial usage observed before message_stop", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ INIT_OK, @@ -267,7 +267,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a tool-bridge turn records input tokens from message_start", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ INIT_OK, @@ -289,7 +289,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("message_stop with an incomplete tool call fails with protocol_error", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ INIT_OK, @@ -311,7 +311,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a terminal result with an incomplete tool call fails with protocol_error", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ INIT_OK, @@ -335,7 +335,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a tool call before the init frame fails closed with tool_bridge_init_missing", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ // A complete tool call arrives before the init frame: the bridge was never validated @@ -361,7 +361,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a result frame before message_stop defers to the synthesized tool_use done", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; let child: FakeChild | undefined; const spawn: SpawnFn = (_cmd, _args) => { @@ -400,7 +400,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a deferred result without message_stop fails closed with protocol_error", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ INIT_OK, @@ -461,7 +461,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a complete assistant tool block without partial capture fails closed", async () => { const p = parsed([tool("exec")]); - const cliName = [...buildCodeBuddyToolBridge(p).emittedNameMap.keys()][0]!; + const cliName = [...buildCodingAgentToolBridge(p).emittedNameMap.keys()][0]!; const adapter = createCodeBuddyAdapter(provider(), { spawn: () => fakeChild(frameLines([ INIT_OK, @@ -478,7 +478,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a complete assistant repeat of a captured partial tool does not duplicate it", async () => { const p = parsed([tool("exec")]); - const cliName = [...buildCodeBuddyToolBridge(p).emittedNameMap.keys()][0]!; + const cliName = [...buildCodingAgentToolBridge(p).emittedNameMap.keys()][0]!; const adapter = createCodeBuddyAdapter(provider(), { spawn: () => fakeChild(frameLines([ INIT_OK, @@ -497,7 +497,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a mixed assistant fallback with an additional uncaptured tool fails closed", async () => { const p = parsed([tool("exec")]); - const cliName = [...buildCodeBuddyToolBridge(p).emittedNameMap.keys()][0]!; + const cliName = [...buildCodingAgentToolBridge(p).emittedNameMap.keys()][0]!; const adapter = createCodeBuddyAdapter(provider(), { spawn: () => fakeChild(frameLines([ INIT_OK, @@ -541,7 +541,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("a tool call that precedes the init handshake fails closed", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; // The call arrives before system/init acknowledged the bridge server, then the handshake and a // clean stop follow. The later init frame must not retroactively legitimize the early call. @@ -582,7 +582,7 @@ describe("CodeBuddy capture-only tool bridge turn", () => { test("more captured calls than the turn limit fails closed", async () => { const p = parsed([tool("exec")]); - const bridge = buildCodeBuddyToolBridge(p); + const bridge = buildCodingAgentToolBridge(p); const cliName = [...bridge.emittedNameMap.keys()][0]!; const frames: unknown[] = [INIT_OK]; for (let i = 0; i < 17; i += 1) { diff --git a/tests/providers/codebuddy-tool-bridge.test.ts b/tests/providers/codebuddy-tool-bridge.test.ts index ccdeccba405..dd83b97d024 100644 --- a/tests/providers/codebuddy-tool-bridge.test.ts +++ b/tests/providers/codebuddy-tool-bridge.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; import { - CODEBUDDY_MCP_TOOL_PREFIX, - CODEBUDDY_TOOL_LIMITS, - buildCodeBuddyToolBridge, - codeBuddyToolAlias, -} from "../../src/adapters/codebuddy/tool-bridge"; + CODING_AGENT_MCP_TOOL_PREFIX, + CODING_AGENT_TOOL_LIMITS, + buildCodingAgentToolBridge, + codingAgentToolAlias, +} from "../../src/adapters/coding-agent/tool-bridge"; import type { OcxParsedRequest, OcxTool, OcxToolChoice } from "../../src/types"; function tool( @@ -32,13 +32,13 @@ function parsed(tools: OcxTool[], toolChoice?: OcxToolChoice): OcxParsedRequest } function wireNames(request: OcxParsedRequest): string[] { - return [...buildCodeBuddyToolBridge(request).emittedNameMap.values()]; + return [...buildCodingAgentToolBridge(request).emittedNameMap.values()]; } function wireToAlias(request: OcxParsedRequest): Map { return new Map( - [...buildCodeBuddyToolBridge(request).emittedNameMap] - .map(([emitted, wire]) => [wire, emitted.slice(CODEBUDDY_MCP_TOOL_PREFIX.length)]), + [...buildCodingAgentToolBridge(request).emittedNameMap] + .map(([emitted, wire]) => [wire, emitted.slice(CODING_AGENT_MCP_TOOL_PREFIX.length)]), ); } @@ -49,26 +49,26 @@ describe("CodeBuddy capture-only tool choice", () => { ]; test("supports auto, none, and required", () => { - const automatic = buildCodeBuddyToolBridge(parsed(catalog, "auto")); + const automatic = buildCodingAgentToolBridge(parsed(catalog, "auto")); expect([...automatic.emittedNameMap.values()]).toEqual(["plain", "mcp__alpha__lookup"]); expect(automatic.requireToolCall).toBe(false); - const none = buildCodeBuddyToolBridge(parsed(catalog, "none")); + const none = buildCodingAgentToolBridge(parsed(catalog, "none")); expect(none.tools).toEqual([]); expect(none.emittedNameMap.size).toBe(0); expect(none.requireToolCall).toBe(false); - const required = buildCodeBuddyToolBridge(parsed(catalog, "required")); + const required = buildCodingAgentToolBridge(parsed(catalog, "required")); expect([...required.emittedNameMap.values()]).toEqual(["plain", "mcp__alpha__lookup"]); expect(required.requireToolCall).toBe(true); }); test("applies none before validating an unadvertised oversized or malformed catalog", () => { const ignored = Array.from( - { length: CODEBUDDY_TOOL_LIMITS.maxTools + 1 }, + { length: CODING_AGENT_TOOL_LIMITS.maxTools + 1 }, (_, index) => tool(`ignored_${index}`, { description: index === 0 - ? "d".repeat(CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + 1) + ? "d".repeat(CODING_AGENT_TOOL_LIMITS.maxDescriptionBytes + 1) : `Ignored ${index}`, parameters: index === 1 ? { type: "array" } @@ -76,7 +76,7 @@ describe("CodeBuddy capture-only tool choice", () => { }), ); - const bridge = buildCodeBuddyToolBridge(parsed(ignored, "none")); + const bridge = buildCodingAgentToolBridge(parsed(ignored, "none")); expect(bridge.tools).toEqual([]); expect(bridge.emittedNameMap.size).toBe(0); expect(bridge.requireToolCall).toBe(false); @@ -88,63 +88,63 @@ describe("CodeBuddy capture-only tool choice", () => { null as unknown as OcxTool, tool("invalid name"), tool("bad_description", { - description: "d".repeat(CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + 1), + description: "d".repeat(CODING_AGENT_TOOL_LIMITS.maxDescriptionBytes + 1), }), tool("bad_schema", { parameters: { type: "array" } }), ...Array.from( - { length: CODEBUDDY_TOOL_LIMITS.maxTools }, + { length: CODING_AGENT_TOOL_LIMITS.maxTools }, (_, index) => tool(`extra_${index}`), ), ]; - const named = buildCodeBuddyToolBridge(parsed([selected, ...ignored], { name: "selected" })); + const named = buildCodingAgentToolBridge(parsed([selected, ...ignored], { name: "selected" })); expect([...named.emittedNameMap.values()]).toEqual(["selected"]); expect(named.requireToolCall).toBe(true); - const allowed = buildCodeBuddyToolBridge(parsed([selected, ...ignored], { + const allowed = buildCodingAgentToolBridge(parsed([selected, ...ignored], { allowedTools: ["selected"], mode: "auto", })); expect([...allowed.emittedNameMap.values()]).toEqual(["selected"]); expect(allowed.requireToolCall).toBe(false); - expect(() => buildCodeBuddyToolBridge(parsed([selected, ...ignored], { + expect(() => buildCodingAgentToolBridge(parsed([selected, ...ignored], { name: "bad_schema", }))).toThrow("invalid input schema"); }); test("supports named selectors including the unique bare namespaced shorthand", () => { for (const name of ["lookup", "mcp__alpha.lookup", "mcp__alpha__lookup"]) { - const bridge = buildCodeBuddyToolBridge(parsed(catalog, { name })); + const bridge = buildCodingAgentToolBridge(parsed(catalog, { name })); expect([...bridge.emittedNameMap.values()]).toEqual(["mcp__alpha__lookup"]); expect(bridge.requireToolCall).toBe(true); } - expect(() => buildCodeBuddyToolBridge(parsed(catalog, { name: "missing" }))) + expect(() => buildCodingAgentToolBridge(parsed(catalog, { name: "missing" }))) .toThrow("tool_choice requires a tool"); }); test("supports allowed_tools in auto and required modes", () => { - const automatic = buildCodeBuddyToolBridge(parsed(catalog, { + const automatic = buildCodingAgentToolBridge(parsed(catalog, { allowedTools: ["plain"], mode: "auto", })); expect([...automatic.emittedNameMap.values()]).toEqual(["plain"]); expect(automatic.requireToolCall).toBe(false); - const required = buildCodeBuddyToolBridge(parsed(catalog, { + const required = buildCodingAgentToolBridge(parsed(catalog, { allowedTools: ["lookup"], mode: "required", })); expect([...required.emittedNameMap.values()]).toEqual(["mcp__alpha__lookup"]); expect(required.requireToolCall).toBe(true); - const noMatch = buildCodeBuddyToolBridge(parsed(catalog, { + const noMatch = buildCodingAgentToolBridge(parsed(catalog, { allowedTools: ["missing"], mode: "auto", })); expect(noMatch.tools).toEqual([]); - expect(() => buildCodeBuddyToolBridge(parsed(catalog, { + expect(() => buildCodingAgentToolBridge(parsed(catalog, { allowedTools: ["missing"], mode: "required", }))).toThrow("tool_choice requires a tool"); @@ -159,7 +159,7 @@ describe("CodeBuddy capture-only tool choice", () => { allowedTools: ["lookup"], mode: "auto", }))).toEqual([]); - expect(() => buildCodeBuddyToolBridge(parsed(ambiguous, { name: "lookup" }))) + expect(() => buildCodingAgentToolBridge(parsed(ambiguous, { name: "lookup" }))) .toThrow("tool_choice requires a tool"); expect(wireNames(parsed(ambiguous, { name: "mcp__beta.lookup" }))) .toEqual(["mcp__beta__lookup"]); @@ -169,7 +169,7 @@ describe("CodeBuddy capture-only tool choice", () => { describe("CodeBuddy tool aliases", () => { test("are deterministic, collision-safe, and reversibly mapped", () => { const unsafeWireName = "unsafe.name"; - const firstHashedCandidate = codeBuddyToolAlias(unsafeWireName); + const firstHashedCandidate = codingAgentToolAlias(unsafeWireName); const catalog = [ tool(unsafeWireName), tool(firstHashedCandidate), @@ -186,13 +186,13 @@ describe("CodeBuddy tool aliases", () => { for (const [wireName, alias] of forward) { expect(alias).toMatch(/^[A-Za-z0-9_-]{1,40}$/); - const bridge = buildCodeBuddyToolBridge(parsed(catalog)); - expect(bridge.emittedNameMap.get(`${CODEBUDDY_MCP_TOOL_PREFIX}${alias}`)).toBe(wireName); + const bridge = buildCodingAgentToolBridge(parsed(catalog)); + expect(bridge.emittedNameMap.get(`${CODING_AGENT_MCP_TOOL_PREFIX}${alias}`)).toBe(wireName); } }); test("rejects duplicate source wire names instead of inventing an ambiguous mapping", () => { - expect(() => buildCodeBuddyToolBridge(parsed([tool("same"), tool("same")]))) + expect(() => buildCodingAgentToolBridge(parsed([tool("same"), tool("same")]))) .toThrow("duplicate wire name"); }); }); @@ -242,7 +242,7 @@ describe("CodeBuddy JSON Schema boundary", () => { additionalProperties: false, encrypted: true, }; - const bridge = buildCodeBuddyToolBridge(parsed([tool("complex", { parameters })])); + const bridge = buildCodingAgentToolBridge(parsed([tool("complex", { parameters })])); const schema = bridge.tools[0].inputSchema as typeof parameters; expect(schema.$defs).toEqual(parameters.$defs); @@ -262,7 +262,7 @@ describe("CodeBuddy JSON Schema boundary", () => { properties: { value: { type: "integer", minimum: 0 } }, required: ["value"], }; - const schema = buildCodeBuddyToolBridge(parsed([tool("normalize", { parameters })])) + const schema = buildCodingAgentToolBridge(parsed([tool("normalize", { parameters })])) .tools[0].inputSchema; expect(schema).toEqual({ ...parameters, type: "object" }); expect(Object.hasOwn(parameters, "type")).toBe(false); @@ -277,7 +277,7 @@ describe("CodeBuddy JSON Schema boundary", () => { ["external reference", { type: "object", properties: { value: { $ref: "https://example.com/schema" } } }], ["non-JSON value", { type: "object", default: undefined }], ])("rejects %s schemas", (_label, parameters) => { - expect(() => buildCodeBuddyToolBridge(parsed([ + expect(() => buildCodingAgentToolBridge(parsed([ tool("invalid", { parameters: parameters as Record }), ]))).toThrow("invalid input schema"); }); @@ -285,7 +285,7 @@ describe("CodeBuddy JSON Schema boundary", () => { test("rejects cyclic and accessor-bearing schemas before serialization", () => { const cyclic: Record = { type: "object" }; cyclic.self = cyclic; - expect(() => buildCodeBuddyToolBridge(parsed([tool("cyclic", { parameters: cyclic })]))) + expect(() => buildCodingAgentToolBridge(parsed([tool("cyclic", { parameters: cyclic })]))) .toThrow(/invalid input schema.*cycles/); const accessor: Record = { type: "object" }; @@ -293,13 +293,13 @@ describe("CodeBuddy JSON Schema boundary", () => { enumerable: true, get: () => ({ value: { type: "string" } }), }); - expect(() => buildCodeBuddyToolBridge(parsed([tool("accessor", { parameters: accessor })]))) + expect(() => buildCodingAgentToolBridge(parsed([tool("accessor", { parameters: accessor })]))) .toThrow(/invalid input schema.*data properties/); }); test("preserves prototype-shaped property names as inert data", () => { const properties = JSON.parse('{"__proto__":{"type":"string"},"constructor":{"type":"number"}}'); - const schema = buildCodeBuddyToolBridge(parsed([ + const schema = buildCodingAgentToolBridge(parsed([ tool("prototype_names", { parameters: { type: "object", properties } }), ])).tools[0].inputSchema; const emitted = schema.properties as Record; @@ -312,49 +312,49 @@ describe("CodeBuddy JSON Schema boundary", () => { describe("CodeBuddy tool catalog limits", () => { test("bounds tool count, name bytes, and description bytes", () => { const tooMany = Array.from( - { length: CODEBUDDY_TOOL_LIMITS.maxTools + 1 }, + { length: CODING_AGENT_TOOL_LIMITS.maxTools + 1 }, (_, index) => tool(`tool_${index}`), ); - expect(() => buildCodeBuddyToolBridge(parsed(tooMany))).toThrow("tool limit"); + expect(() => buildCodingAgentToolBridge(parsed(tooMany))).toThrow("tool limit"); - const oversizedName = "é".repeat(Math.floor(CODEBUDDY_TOOL_LIMITS.maxNameBytes / 2) + 1); - expect(() => buildCodeBuddyToolBridge(parsed([tool(oversizedName)]))).toThrow("name exceeds"); + const oversizedName = "é".repeat(Math.floor(CODING_AGENT_TOOL_LIMITS.maxNameBytes / 2) + 1); + expect(() => buildCodingAgentToolBridge(parsed([tool(oversizedName)]))).toThrow("name exceeds"); - const oversizedDescription = "d".repeat(CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + 1); - expect(() => buildCodeBuddyToolBridge(parsed([ + const oversizedDescription = "d".repeat(CODING_AGENT_TOOL_LIMITS.maxDescriptionBytes + 1); + expect(() => buildCodingAgentToolBridge(parsed([ tool("large_description", { description: oversizedDescription }), ]))).toThrow("description exceeds"); }); test("bounds schema depth and node count before JSON serialization", () => { let tooDeep: Record = { type: "string" }; - for (let depth = 0; depth <= CODEBUDDY_TOOL_LIMITS.maxSchemaDepth; depth++) { + for (let depth = 0; depth <= CODING_AGENT_TOOL_LIMITS.maxSchemaDepth; depth++) { tooDeep = { nested: tooDeep }; } - expect(() => buildCodeBuddyToolBridge(parsed([ + expect(() => buildCodingAgentToolBridge(parsed([ tool("deep", { parameters: { type: "object", extension: tooDeep } }), ]))).toThrow(/invalid input schema.*too deep/); const tooManyNodes = Array.from( - { length: CODEBUDDY_TOOL_LIMITS.maxSchemaNodes }, + { length: CODING_AGENT_TOOL_LIMITS.maxSchemaNodes }, (_, index) => `value_${index}`, ); - expect(() => buildCodeBuddyToolBridge(parsed([ + expect(() => buildCodingAgentToolBridge(parsed([ tool("nodes", { parameters: { type: "object", enum: tooManyNodes } }), ]))).toThrow(/invalid input schema.*node count/); }); test("bounds schema, individual definition, and aggregate catalog bytes independently", () => { - expect(() => buildCodeBuddyToolBridge(parsed([ + expect(() => buildCodingAgentToolBridge(parsed([ tool("large_schema", { parameters: { type: "object", - $comment: "s".repeat(CODEBUDDY_TOOL_LIMITS.maxSchemaBytes), + $comment: "s".repeat(CODING_AGENT_TOOL_LIMITS.maxSchemaBytes), }, }), ]))).toThrow(/invalid input schema.*schema exceeds/); - expect(() => buildCodeBuddyToolBridge(parsed([ + expect(() => buildCodingAgentToolBridge(parsed([ tool("large_definition", { description: "d".repeat(60 * 1024), parameters: { type: "object", $comment: "s".repeat(200 * 1024) }, @@ -365,6 +365,6 @@ describe("CodeBuddy tool catalog limits", () => { { length: 40 }, (_, index) => tool(`aggregate_${index}`, { description: "d".repeat(55 * 1024) }), ); - expect(() => buildCodeBuddyToolBridge(parsed(aggregate))).toThrow("catalog exceeds"); + expect(() => buildCodingAgentToolBridge(parsed(aggregate))).toThrow("catalog exceeds"); }); }); From a5c5ccded08e2a151711aeaddee36f6dc53aa456 Mon Sep 17 00:00:00 2001 From: Robin Bially <7304732+RobinBially@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:39:28 +0200 Subject: [PATCH 2/2] fix(claude-cli): correct the --max-turns claim and align the registry note The adapter comment claimed the CLI exposes no --max-turns. It does carry one, but hidden from --help, and it is not the bound this leg relies on: against a capture-only server on 2.1.282 the flag produced a terminal result frame of subtype error_max_turns while the process stayed open behind the never-answering MCP child. The comment now names the real bound (turn.ts terminating the tree at message_stop) and the test records the same measured reason. Also indents the claude-cli registry note to the four spaces its neighbours use. --- src/adapters/claude-cli/adapter.ts | 9 +++++++-- src/providers/registry/entries-extended.ts | 2 +- tests/providers/claude-cli-adapter.test.ts | 8 +++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts index 2857b31272d..6c87cf23db2 100644 --- a/src/adapters/claude-cli/adapter.ts +++ b/src/adapters/claude-cli/adapter.ts @@ -95,8 +95,13 @@ export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Reco * `--no-session-persistence` keeps every turn stateless. The client replays its own conversation * and `buildConversationInput` projects it into the single stream-json user frame the CLI accepts. * - * There is deliberately no `--max-turns` here: the Claude Code CLI exposes no such flag (the Agent - * SDK sets it on the turn budget instead), and with no tool channel a single `-p` turn cannot loop. + * There is deliberately no `--max-turns` here. The CLI does carry one — "maximum number of agentic + * turns in non-interactive mode" — but it is hidden from `--help`, and a measured turn against a + * capture-only server does not end any earlier with it: 2.1.282 emitted a terminal `result` frame + * with `subtype: "error_max_turns"` and then held the process open behind the never-answering MCP + * child anyway. The leg is bounded where the turn actually ends, in `turn.ts`: without a catalog the + * single `-p` turn has no tool channel to loop in, and with one the adapter terminates the process + * tree at `message_stop` once the captured calls are complete. */ export function buildArgs( _profile: ClaudeCliProfile, diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 2b9e2ce2e95..5c3d10ad316 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1487,6 +1487,6 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ reasoningEfforts: ANTHROPIC_REASONING_EFFORTS, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, - note: "Runs Claude subscription traffic through Anthropic's own harness: the official Claude Code CLI headlessly (`claude -p`), one turn per request. OpenCodex stores no Claude token, reads none and injects none — the CLI signs in and bills the account itself, which is why this row is keyless and an API key saved here never reaches the harness (use `anthropic-apikey` for key billing). The sign-in is the one of the user this proxy runs as, so every request served through this row — by any client of this proxy — spends that same account; OpenCodex neither pools nor multiplexes Claude sign-ins. Requires the CLI (`npm i -g @anthropic-ai/claude-code`) and a signed-in session (`claude` -> /login). The CLI always runs tools-disabled (--tools \"\"); a capture-only MCP bridge surfaces the request's Codex tool catalog as capturable calls, with approval and execution kept by the client. Subscription routing authorization flagged for maintainer review.", + note: "Runs Claude subscription traffic through Anthropic's own harness: the official Claude Code CLI headlessly (`claude -p`), one turn per request. OpenCodex stores no Claude token, reads none and injects none — the CLI signs in and bills the account itself, which is why this row is keyless and an API key saved here never reaches the harness (use `anthropic-apikey` for key billing). The sign-in is the one of the user this proxy runs as, so every request served through this row — by any client of this proxy — spends that same account; OpenCodex neither pools nor multiplexes Claude sign-ins. Requires the CLI (`npm i -g @anthropic-ai/claude-code`) and a signed-in session (`claude` -> /login). The CLI always runs tools-disabled (--tools \"\"); a capture-only MCP bridge surfaces the request's Codex tool catalog as capturable calls, with approval and execution kept by the client. Subscription routing authorization flagged for maintainer review.", }, ]; diff --git a/tests/providers/claude-cli-adapter.test.ts b/tests/providers/claude-cli-adapter.test.ts index 86d2c164b4f..b121d210c59 100644 --- a/tests/providers/claude-cli-adapter.test.ts +++ b/tests/providers/claude-cli-adapter.test.ts @@ -163,9 +163,11 @@ describe("claude-cli headless arguments keep tool ownership with the client", () expect(args[args.indexOf("--effort") + 1]).toBe("high"); }); - test("passes no --max-turns: the Claude Code CLI has no such flag", () => { - // CodeBuddy's CLI accepts --max-turns and this family shares its parser; the flag must not be - // copied across, or every turn dies on an unknown option. + test("passes no --max-turns: the capture leg is bounded by the message_stop termination", () => { + // The CLI does accept a hidden --max-turns (it is absent from --help), but a capture-only turn + // measured on 2.1.282 ended with `subtype: "error_max_turns"` and still held the process open. + // The bound is turn.ts terminating the tree at `message_stop`, so the family's CodeBuddy flag is + // not copied across on the strength of that name alone. expect(buildArgs(CLAUDE_CLI_PROFILE, parsed(), provider())).not.toContain("--max-turns"); }); });