diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e8cba6c928d..1dd4d25676c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -831,8 +831,10 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C - Global: [CodeBuddy Global API Keys](https://www.codebuddy.ai/profile/keys) - CN: [CodeBuddy CN API Keys](https://copilot.tencent.com/profile/keys) - **Region Isolation:** `codebuddy` and `codebuddy-cn` use separate canonical endpoints (`https://www.codebuddy.ai` and `https://www.codebuddy.cn`) and isolated child environments (`CODEBUDDY_INTERNET_ENVIRONMENT=public` vs `internal`). Credentials are strictly region-scoped and never exchanged across environments. Overriding the canonical base URL fails closed. -- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text. +- **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 a capture-only MCP bridge: 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 as `function_call` items (names mapped back to the request's wire names, at most 16 calls per assistant message), the process tree is terminated at `message_stop`, and the external Codex client alone performs approval, sandboxing, and execution. Tool results come back as the next request's input, and the conversation continues. Requests without tools keep the plain text-and-reasoning shape. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text. - **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement. +- **Tool Choice Enforcement:** When a request specifies `tool_choice: "required"` or selects a specific named tool, the bridge expects a tool call from the model. If the CLI completes the turn with plain text instead of capturing a tool call, OpenCodex fails closed with a 502 `tool_call_required` error rather than returning an invalid text completion. +- **Governance Status:** Whether routing this vendor automation surface behind a proxy for a third-party agent satisfies CodeBuddy's acceptable-use terms is an open question flagged for maintainer security review (see the governance note in the provider registry entry). Treat this provider as pending that review, and keep the tool bridge's ownership boundary in mind: the nested CLI advertises tools but never executes them, and approval, sandboxing, and execution remain with the external Codex client. ### Official Qoder CLI (Global & CN) diff --git a/scripts/codebuddy-live-acceptance.ts b/scripts/codebuddy-live-acceptance.ts new file mode 100644 index 00000000000..7ef7c051412 --- /dev/null +++ b/scripts/codebuddy-live-acceptance.ts @@ -0,0 +1,409 @@ +/** + * Explicitly opt-in SYNTHETIC adapter acceptance against an authenticated CodeBuddy CLI. + * CODEBUDDY_LIVE_TEST=1, an absolute CODEBUDDY_LIVE_CLI_PATH, and CODEBUDDY_LIVE_API_KEY are + * required. CODEBUDDY_LIVE_REGION selects the "global" (default) or "cn" preset, and the key + * must belong to that region. + * + * Consumes three subscription turns. Real HOME is retained for the official CLI; OpenCodex and + * Codex state and the listener are disposable. Do not run through `bun test`: its preload + * deliberately replaces the real login home. No Codex client is executed and tool results are + * fabricated by this script. Only scoped fixed-code results are printed; the API key, runtime + * logs, and response text are not. + */ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; + +type RecordValue = Record; +type Stop = () => void | Promise; +type Fetch = (input: URL, init: RequestInit) => Promise; +const DEFAULT_MODEL = "kimi-k2.5"; +const DEADLINE_MS = 240_000; +const CLEANUP_DEADLINE_MS = 10_000; +const MAX_STREAM_BYTES = 2 * 1024 * 1024; +const MAX_EVENTS = 10_000; + +export class AcceptanceFailure extends Error { + constructor(readonly code: string) { super(code); } +} + +function requireCondition(value: unknown, code: string): asserts value { + if (!value) throw new AcceptanceFailure(code); +} + +function record(value: unknown): value is RecordValue { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function assertLiveOptIn(env: NodeJS.ProcessEnv): void { + requireCondition(env.CODEBUDDY_LIVE_TEST === "1", "explicit_opt_in_required"); + requireCondition(!env.OCX_TEST_HOME_GUARD && !env.OCX_TEST_PRELOAD_PID + && !env.OCX_REAL_HOME && !env.BUN_TEST_WORKER_ID, "test_preload_not_supported"); + liveAcceptanceCliPath(env); + liveAcceptanceModel(env); + liveAcceptanceApiKey(env); + liveAcceptanceRegion(env); +} + +/** Diagnostic comparisons must never silently select a different installation. */ +export function liveAcceptanceCliPath(env: NodeJS.ProcessEnv): string { + const path = env.CODEBUDDY_LIVE_CLI_PATH?.trim(); + requireCondition(path, "explicit_cli_path_required"); + requireCondition(isAbsolute(path), "cli_path_must_be_absolute"); + return path; +} + +/** Region preset under test; the account key must belong to the same region. */ +export function liveAcceptanceRegion(env: NodeJS.ProcessEnv): "global" | "cn" { + const region = env.CODEBUDDY_LIVE_REGION ?? "global"; + requireCondition(region === "global" || region === "cn", "invalid_region"); + return region; +} + +/** Required and never printed: the region's console API key the CLI authenticates with. */ +export function liveAcceptanceApiKey(env: NodeJS.ProcessEnv): string { + const key = env.CODEBUDDY_LIVE_API_KEY?.trim(); + requireCondition(key, "explicit_api_key_required"); + return key; +} + +export function syntheticAcceptanceResult(passed: boolean, error?: unknown) { + return { + passed, + code: passed ? "synthetic_three_turn_streaming_passed" + : error instanceof AcceptanceFailure ? error.code : "acceptance_failed", + scope: "synthetic-adapter" as const, + codexClientExecuted: false as const, + }; +} + +/** An exact CLI selector, never a different provider or an arbitrary CLI argument. */ +export function liveAcceptanceModel(env: NodeJS.ProcessEnv): string { + const model = env.CODEBUDDY_LIVE_MODEL ?? DEFAULT_MODEL; + requireCondition(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(model), "invalid_model_selector"); + return model; +} + +export function assertLoopbackListener(listener: { port: number | undefined; hostname: string | undefined; url: URL }): void { + requireCondition(Number.isInteger(listener.port) && listener.port! > 0 + // 10100 is the only live proxy port now: the dev instance shares it with the + // official one (they never run at once). The retired dev port 10110 must NOT + // stay excluded, or a foreign listener there would go unflagged. + && listener.port! <= 65535 && listener.port !== 10100, "unsafe_listener_port"); + requireCondition(listener.hostname === "127.0.0.1" && listener.url.hostname === "127.0.0.1" + && listener.url.protocol === "http:" && Number(listener.url.port) === listener.port + && !listener.url.username && !listener.url.password, "unsafe_listener_address"); +} + +/** A single deadline/signal spans fetch and every read across all three turns. */ +export async function abortable(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw new AcceptanceFailure("acceptance_aborted"); + let abort!: () => void; + const interrupted = new Promise((_, reject) => { + abort = () => reject(new AcceptanceFailure("acceptance_aborted")); + signal.addEventListener("abort", abort, { once: true }); + }); + try { return await Promise.race([operation, interrupted]); } + finally { signal.removeEventListener("abort", abort); } +} + +export interface StreamResult { + response: RecordValue; + events: RecordValue[]; +} + +/** Strict bounded SSE parsing; even a completed event cannot hide a torn tail. */ +export async function readResponseStream(response: Response, signal: AbortSignal, model = DEFAULT_MODEL): Promise { + requireCondition(response.status === 200, "unexpected_http_status"); + requireCondition(response.headers.get("content-type")?.split(";")[0]?.trim() === "text/event-stream", + "unexpected_content_type"); + requireCondition(response.body, "missing_response_stream"); + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + const events: RecordValue[] = []; + let buffer = ""; + let bytes = 0; + let completed: RecordValue | undefined; + let done = false; + const consume = (frame: string) => { + if (!frame || frame.split("\n").every(line => !line || line.startsWith(":"))) return; + let name: string | undefined; + const data: string[] = []; + for (const line of frame.split("\n")) { + if (!line || line.startsWith(":")) continue; + const separator = line.indexOf(":"); + requireCondition(separator >= 0, "malformed_sse"); + const field = line.slice(0, separator); + const value = line.slice(separator + 1).replace(/^ /, ""); + if (field === "event") { + requireCondition(name === undefined, "malformed_sse"); + name = value; + } else if (field === "data") data.push(value); + else requireCondition(field === "id" || field === "retry", "malformed_sse"); + } + requireCondition(data.length > 0, "malformed_sse"); + const payload = data.join("\n"); + if (payload === "[DONE]") { + requireCondition(completed && !done, "premature_or_duplicate_done"); + done = true; + return; + } + requireCondition(!done && !completed, "event_after_completion"); + let parsed: unknown; + try { parsed = JSON.parse(payload); } catch { throw new AcceptanceFailure("malformed_sse_json"); } + requireCondition(record(parsed) && typeof parsed.type === "string" + && (!name || name === parsed.type), "malformed_sse_event"); + requireCondition(parsed.type !== "error" && parsed.type !== "response.failed" + && parsed.type !== "response.incomplete", "response_failed"); + requireCondition(events.length < MAX_EVENTS, "stream_limit_exceeded"); + events.push(parsed); + if (parsed.type === "response.completed") { + requireCondition(record(parsed.response), "invalid_completed_response"); + completed = parsed.response; + } + }; + try { + while (true) { + const next = await abortable(reader.read(), signal); + if (next.done) break; + bytes += next.value.byteLength; + requireCondition(bytes <= MAX_STREAM_BYTES, "stream_limit_exceeded"); + buffer += decoder.decode(next.value, { stream: true }); + // Normalize only complete CRLF pairs, including ones split across chunks. + buffer = buffer.replace(/\r\n/g, "\n"); + let end: number; + while ((end = buffer.indexOf("\n\n")) >= 0) { + consume(buffer.slice(0, end)); + buffer = buffer.slice(end + 2); + } + } + buffer += decoder.decode(); + requireCondition(buffer.length === 0, "truncated_sse_frame"); + requireCondition(completed, "missing_completion"); + requireCondition(done, "missing_done"); + requireCondition(completed.status === "completed" && completed.model === model + && typeof completed.id === "string" && completed.id.length > 0 && Array.isArray(completed.output), + "invalid_completed_response"); + return { response: completed, events }; + } catch (error) { + void reader.cancel().catch(() => {}); + throw error instanceof AcceptanceFailure ? error : new AcceptanceFailure("stream_read_failed"); + } finally { + reader.releaseLock(); + } +} + +/** + * A tool leg ends at message_stop with no vendor result frame, so positive usage on the + * completed response is the regression guard for partial-usage accounting on bridge turns. + * Both token fields must be positive: a single-field check passed even when the synthesized + * tool leg reported zero input tokens (the message_start omission this harness now guards). + */ +function assertReportedUsage(response: RecordValue): void { + requireCondition(record(response.usage), "usage_missing"); + const inputTokens = typeof response.usage.input_tokens === "number" ? response.usage.input_tokens : 0; + const outputTokens = typeof response.usage.output_tokens === "number" ? response.usage.output_tokens : 0; + requireCondition(inputTokens > 0 && outputTokens > 0, "usage_zero"); +} + +export function argumentsMatch(actual: unknown, expected: RecordValue): boolean { + if (typeof actual !== "string") return false; + try { + const parsed: unknown = JSON.parse(actual); + return record(parsed) && Object.keys(parsed).length === Object.keys(expected).length + && Object.entries(expected).every(([key, value]) => Object.hasOwn(parsed, key) && parsed[key] === value); + } catch { return false; } +} + +function validateDeltas(result: StreamResult, itemId: string, kind: "function_call_arguments" | "output_text", + expected: string, contentIndex?: number): void { + const matches = (event: RecordValue) => event.item_id === itemId + && (contentIndex === undefined || event.content_index === contentIndex); + const deltas = result.events.filter(event => event.type === `response.${kind}.delta` && matches(event)); + const dones = result.events.filter(event => event.type === `response.${kind}.done` && matches(event)); + requireCondition(deltas.length > 0 && deltas.every(event => typeof event.delta === "string") + && deltas.map(event => event.delta).join("") === expected && dones.length === 1 + && dones[0]![kind === "output_text" ? "text" : "arguments"] === expected, "stream_snapshot_mismatch"); + const doneIndex = result.events.indexOf(dones[0]!); + requireCondition(deltas.every(event => result.events.indexOf(event) < doneIndex), "delta_after_done"); +} + +function toolCall(result: StreamResult, name: string, expected: RecordValue): RecordValue { + const calls = (result.response.output as unknown[]).filter(item => record(item) && item.type === "function_call"); + requireCondition(calls.length === 1 && record(calls[0]), "unexpected_tool_count"); + const call = calls[0]; + requireCondition(call.name === name && typeof call.id === "string" && call.id.length > 0 + && typeof call.call_id === "string" && call.call_id.length > 0 + && argumentsMatch(call.arguments, expected), "tool_call_mismatch"); + validateDeltas(result, call.id, "function_call_arguments", call.arguments as string); + return call; +} + +export async function runAcceptanceScenario(baseUrl: URL, signal: AbortSignal, fetchResponse: Fetch = fetch, + model = DEFAULT_MODEL, providerId = "codebuddy"): Promise { + liveAcceptanceModel({ CODEBUDDY_LIVE_MODEL: model }); + assertLoopbackListener({ url: baseUrl, hostname: baseUrl.hostname, port: Number(baseUrl.port) }); + // Exercise selected Codex protocol properties, NOT a real client: a turn_id is reused across every + // Responses request of the turn (including continuations after tool outputs), + // and parallel_tool_calls arrives as permission even though the published + // catalog serializes tool calls. + const turnMetadata = JSON.stringify({ turn_id: crypto.randomUUID() }); + const post = async (body: RecordValue) => readResponseStream(await abortable(fetchResponse( + new URL("/v1/responses", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", "x-codex-turn-metadata": turnMetadata }, + body: JSON.stringify({ ...body, model: `${providerId}/${model}`, stream: true, parallel_tool_calls: true }), signal, + }), signal), signal, model); + const first = await post({ + input: "Call lookup_inventory exactly once with sku TEST-123. Do not answer with text.", + tools: [{ type: "function", name: "lookup_inventory", description: "Look up inventory for an exact SKU.", + parameters: { type: "object", properties: { sku: { type: "string" } }, required: ["sku"], additionalProperties: false } }], + tool_choice: "required", + }); + assertReportedUsage(first.response); + const lookup = toolCall(first, "lookup_inventory", { sku: "TEST-123" }); + const second = await post({ + previous_response_id: first.response.id, + input: [ + { type: "function_call_output", call_id: lookup.call_id, output: JSON.stringify({ available: 7 }) }, + { type: "message", role: "user", content: [{ type: "input_text", text: + "Only if the preceding tool result says available is exactly 7, call reserve_inventory once for sku TEST-123 with quantity 2. Otherwise answer UNAVAILABLE without a tool." }] }, + ], + tools: [{ type: "function", name: "reserve_inventory", description: "Reserve a quantity of an exact SKU.", + parameters: { type: "object", properties: { sku: { type: "string" }, quantity: { type: "integer", minimum: 1 } }, + required: ["sku", "quantity"], additionalProperties: false } }], + tool_choice: "required", + }); + assertReportedUsage(second.response); + const reserve = toolCall(second, "reserve_inventory", { sku: "TEST-123", quantity: 2 }); + const third = await post({ + previous_response_id: second.response.id, + input: [ + { type: "function_call_output", call_id: reserve.call_id, + output: JSON.stringify({ reservation_id: "R-42", reserved: true }) }, + { type: "message", role: "user", content: [{ type: "input_text", text: + "Return only the exact reservation_id from the preceding tool result, with no other text or tool call." }] }, + ], + tools: [], tool_choice: "none", + }); + let finalText = ""; + for (const item of third.response.output as unknown[]) { + requireCondition(record(item) && item.type !== "function_call", "unexpected_final_tool_call"); + if (item.type !== "message" || !Array.isArray(item.content)) continue; + requireCondition(typeof item.id === "string", "invalid_message_id"); + item.content.forEach((part: unknown, index: number) => { + if (!record(part) || part.type !== "output_text") return; + requireCondition(typeof part.text === "string", "invalid_final_text"); + validateDeltas(third, item.id as string, "output_text", part.text, index); + finalText += part.text; + }); + } + requireCondition(finalText.trim() === "R-42", "final_result_mismatch"); +} + +/** Nested cleanup owns the exact directory created here, even when stop rejects. */ +export async function withIsolatedState(work: (state: { + openCodexHome: string; codexHome: string; registerStop: (stop: Stop) => void; +}) => Promise): Promise { + const previous = { ...process.env }; + const root = await mkdtemp(join(tmpdir(), "ocx-codebuddy-live-")); + let stop: Stop | undefined; + try { + const openCodexHome = join(root, "opencodex-home"); + const codexHome = join(root, "codex-home"); + await Promise.all([mkdir(openCodexHome), mkdir(codexHome)]); + process.env.OPENCODEX_HOME = openCodexHome; + process.env.CODEX_HOME = codexHome; + return await work({ openCodexHome, codexHome, registerStop: value => { stop = value; } }); + } finally { + try { + if (stop) { + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), CLEANUP_DEADLINE_MS); + try { await abortable(Promise.resolve().then(stop), timeout.signal); } + finally { clearTimeout(timer); } + } + } finally { + try { + for (const key of Object.keys(process.env)) if (!(key in previous)) delete process.env[key]; + for (const [key, value] of Object.entries(previous)) process.env[key] = value; + } finally { await rm(root, { recursive: true, force: true }); } + } + } +} + +export async function runLiveAcceptance(): Promise { + assertLiveOptIn(process.env); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DEADLINE_MS); + const interrupt = () => controller.abort(); + process.on("SIGINT", interrupt); + process.on("SIGTERM", interrupt); + try { + await withIsolatedState(async ({ openCodexHome, codexHome, registerStop }) => { + // Runtime modules may capture paths at import. Import only after isolation. + const { getConfigDir } = await import("../src/config/paths"); + const { saveConfig } = await import("../src/config"); + const { resolveCodexHomeDir } = await import("../src/codex/home"); + const { providerConfigSeed } = await import("../src/providers/derive"); + const { getProviderRegistryEntry } = await import("../src/providers/registry"); + const { startServer } = await import("../src/server"); + const { CODEBUDDY_PROFILES, clearCodeBuddyBinaryCache } = await import("../src/adapters/codebuddy/profiles"); + const { resolveCodingAgentBinary } = await import("../src/adapters/coding-agent/profile"); + requireCondition(getConfigDir() === resolve(openCodexHome) + && resolveCodexHomeDir() === resolve(codexHome), "home_isolation_failed"); + const providerId = liveAcceptanceRegion(process.env) === "cn" ? "codebuddy-cn" : "codebuddy"; + const entry = getProviderRegistryEntry(providerId); + requireCondition(entry, "provider_not_registered"); + const profile = CODEBUDDY_PROFILES.find(candidate => candidate.providerId === providerId); + requireCondition(profile, "provider_not_registered"); + // The adapter resolves the CLI from PATH at request time. Front-load the operator-selected + // installation and fail closed when that resolution does not match it exactly. + const cliPath = liveAcceptanceCliPath(process.env); + process.env.PATH = `${dirname(cliPath)}${delimiter}${process.env.PATH ?? ""}`; + clearCodeBuddyBinaryCache(); + requireCondition(resolveCodingAgentBinary(profile) === cliPath, "cli_resolution_mismatch"); + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: providerId, + providers: { [providerId]: { ...providerConfigSeed(entry), apiKey: liveAcceptanceApiKey(process.env) } }, + }); + requireCondition(!controller.signal.aborted, "acceptance_aborted"); + const server = startServer(0); + registerStop(() => server.stop(true)); + assertLoopbackListener(server); + try { await runAcceptanceScenario(server.url, controller.signal, fetch, liveAcceptanceModel(process.env), providerId); } + finally { controller.abort(); } + }); + } finally { + clearTimeout(timer); + process.removeListener("SIGINT", interrupt); + process.removeListener("SIGTERM", interrupt); + } +} + +if (import.meta.main) { + const stdout = process.stdout.write; + const stderr = process.stderr.write; + // Bun's console can bypass process.stdout.write, so suppress that surface too. + const savedConsole = { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug }; + console.log = console.info = console.warn = console.error = console.debug = () => {}; + // Runtime diagnostics can contain provider output. This standalone process has + // one owner; suppress both channels for its entire operation and cleanup. + process.stdout.write = (() => true) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + let result: ReturnType; + try { + await runLiveAcceptance(); + result = syntheticAcceptanceResult(true); + } catch (error) { + result = syntheticAcceptanceResult(false, error); + } finally { + process.stdout.write = stdout; + process.stderr.write = stderr; + Object.assign(console, savedConsole); + } + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exit(result.passed ? 0 : 1); +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a03b7b3d552..f3a468d3473 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -459,7 +459,11 @@ "cline-writer.test.ts": "clients", "closed-pr-branch-cleanup.test.ts": "ci-workflows", "codebuddy-adapter.test.ts": "providers", + "codebuddy-live-acceptance.test.ts": "providers", + "codebuddy-mcp-server.test.ts": "providers", "codebuddy-protocol.test.ts": "providers", + "codebuddy-tool-bridge-turn.test.ts": "providers", + "codebuddy-tool-bridge.test.ts": "providers", "codex-account-delete-atomicity.test.ts": "codex-integration", "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index 67d6c0762a9..705393ec3de 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -2,16 +2,44 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../ty 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 { AdapterRequest, ProviderAdapter } from "../base"; import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; -import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type SpawnFn } from "../coding-agent/turn"; +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"; 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"); + /** * Build the scoped child-process environment for a CodeBuddy turn (§六/§十四). * @@ -35,8 +63,9 @@ export function buildChildEnv(profile: CodeBuddyProfile, apiKey: string): Record * Tool ownership stays with Codex: `--tools ""` disables every built-in tool and `--strict-mcp-config` * (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; - * Codex's tool catalog is not advertised in v1 (the control-protocol tool bridge is a fast-follow). + * 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. */ export function buildArgs( profile: CodeBuddyProfile, @@ -80,16 +109,45 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu }, async runTurn(parsed, incoming, emit): Promise { - // argv is world-readable via process listing, so the folded system+developer prompt is - // staged in a private temp file and passed by path instead of embedded in the arguments. + let toolBridge: CodeBuddyToolBridge; + try { + toolBridge = buildCodeBuddyToolBridge(parsed); + } catch (err) { + emit({ + type: "error", + message: `Invalid CodeBuddy 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 | 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; + // 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); + const staged = systemParts.length > 0 ? systemParts.join("\n\n") : undefined; let promptDir: string | undefined; let promptFile: string | undefined; - if (system) { + if (staged) { try { promptDir = await mkdtemp(join(tmpdir(), "ocx-codebuddy-prompt-")); promptFile = join(promptDir, "system-prompt.txt"); - await writeFile(promptFile, system, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await writeFile(promptFile, staged, { encoding: "utf8", mode: 0o600, flag: "wx" }); } catch { if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); emit({ @@ -111,6 +169,7 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu parsed, incoming, emit: guardCodeBuddyScaffolding(emit), + ...(bridgeInput ? { toolBridge: bridgeInput } : {}), buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov, promptFile), buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey), deps, diff --git a/src/adapters/codebuddy/mcp-server.ts b/src/adapters/codebuddy/mcp-server.ts new file mode 100644 index 00000000000..c585467353a --- /dev/null +++ b/src/adapters/codebuddy/mcp-server.ts @@ -0,0 +1,187 @@ +/** + * Isolated MCP catalog used by the CodeBuddy adapter. + * + * This process advertises the current Codex tool schemas but deliberately never + * executes a call. The parent adapter captures CodeBuddy'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. + */ + +import { open } from "node:fs/promises"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { CODEBUDDY_TOOL_LIMITS } from "./tool-bridge"; + +interface ToolDefinition { + name: string; + description: string; + inputSchema: Record; +} + +const MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,40}$/; +const INVALID_DESCRIPTION_CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u; +const textEncoder = new TextEncoder(); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function utf8Bytes(value: string): number { + return textEncoder.encode(value).byteLength; +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +async function readCatalogBounded(path: string): Promise { + const handle = await open(path, "r"); + 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) { + 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); + 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) { + throw new Error("tool catalog is too large"); + } + + const after = await handle.stat(); + if ( + before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || before.mtimeMs !== after.mtimeMs + || before.ctimeMs !== after.ctimeMs + || after.size !== offset + ) { + throw new Error("tool catalog changed while being read"); + } + return bytes.subarray(0, offset); + } finally { + await handle.close(); + } +} + +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) { + throw new Error("tool input schema is too large"); + } + + let nodes = 0; + const pending: Array<{ depth: number; value: unknown }> = [{ depth: 0, value: schema }]; + while (pending.length > 0) { + const current = pending.pop()!; + nodes += 1; + if (nodes > CODEBUDDY_TOOL_LIMITS.maxSchemaNodes) { + throw new Error("tool input schema has too many nodes"); + } + if (current.depth > CODEBUDDY_TOOL_LIMITS.maxSchemaDepth) { + throw new Error("tool input schema is too deeply nested"); + } + if (Array.isArray(current.value)) { + for (const child of current.value) pending.push({ depth: current.depth + 1, value: child }); + } else if (isRecord(current.value)) { + for (const child of Object.values(current.value)) { + pending.push({ depth: current.depth + 1, value: child }); + } + } + } +} + +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) { + throw new Error("tool catalog contains too many definitions"); + } + + const names = new Set(); + return parsed.map((value): ToolDefinition => { + if ( + !isRecord(value) + || typeof value.name !== "string" + || !MCP_TOOL_NAME_PATTERN.test(value.name) + || utf8Bytes(value.name) > CODEBUDDY_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 + || !isRecord(value.inputSchema) + ) { + throw new Error("tool catalog contains an invalid definition"); + } + if (names.has(value.name)) throw new Error("tool catalog contains duplicate names"); + names.add(value.name); + assertBoundedSchema(value.inputSchema); + const definition = { + name: value.name, + description: value.description, + inputSchema: value.inputSchema, + }; + if (utf8Bytes(JSON.stringify(definition)) > CODEBUDDY_TOOL_LIMITS.maxToolBytes) { + throw new Error("tool catalog contains an oversized definition"); + } + return definition; + }); +} + +const catalogPath = process.argv[2]; +if (!catalogPath) throw new Error("missing tool catalog"); + +// Exit when stdin closes. The MCP stdio binding expects servers to exit on stdin EOF, and the +// pinned SDK (1.30.0) does not detect EOF itself: without this, the capture server would outlive +// the CLI it serves — whenever the parent terminates the CLI (message_stop capture path, timeout, +// crash), the pipe's write end closes, and this server must follow instead of lingering as an +// orphaned bun process parked on the never-answering CallTool promise. +const exitOnStdinClose = (): void => process.exit(0); +process.stdin.on("end", exitOnStdinClose); +process.stdin.on("close", exitOnStdinClose); + +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" }, + { capabilities: { tools: {} } }, +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); +server.setRequestHandler(CallToolRequestSchema, async request => { + if (!advertisedNames.has(request.params.name)) { + throw new Error("unknown isolated tool"); + } + // A pending Promise does not execute anything and keeps the CodeBuddy turn + // parked until the parent has captured message_stop and terminates the tree. + return await new Promise(() => {}); +}); + +await server.connect(new StdioServerTransport()); diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 707f90ae1c0..6f913233e1e 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -28,9 +28,28 @@ interface ScanResult { lineStart: boolean; } +// Probe with ASCII-only case folding rather than toLowerCase(): Unicode lowercasing can expand +// a code point (İ, ʼn, ligatures), shifting folded-text offsets away from `text` positions and +// silently disabling detection. `expected` must already be lowercase. +function asciiFold(code: number): number { + return code >= 0x41 && code <= 0x5a ? code + 0x20 : code; +} + +function startsWithFolded(text: string, index: number, expected: string): boolean { + if (index + expected.length > text.length) return false; + for (let i = 0; i < expected.length; i++) { + if (asciiFold(text.charCodeAt(index + i)) !== expected.charCodeAt(i)) return false; + } + return true; +} + function prefixAtEnd(text: string, at: number, expected: string): boolean { - const rest = text.slice(at).toLowerCase(); - return rest.length < expected.length && expected.startsWith(rest); + const remaining = text.length - at; + if (remaining > expected.length) return false; + for (let i = 0; i < remaining; i++) { + if (asciiFold(text.charCodeAt(at + i)) !== expected.charCodeAt(i)) return false; + } + return true; } /** @@ -65,8 +84,7 @@ function scan( } if (!fence) { - const lowered = text.slice(index).toLowerCase(); - if (lowered.startsWith(DSML_CALLS_LINE)) { + if (startsWithFolded(text, index, DSML_CALLS_LINE)) { const afterCalls = index + DSML_CALLS_LINE.length; let invokeAt = -1; if (text[afterCalls] === "\n") invokeAt = afterCalls + 1; @@ -76,12 +94,11 @@ function scan( } if (invokeAt >= 0) { - const invokeRest = text.slice(invokeAt).toLowerCase(); - const invokeNameStart = invokeRest[DSML_INVOKE_PREFIX.length]; - if (invokeRest.startsWith(DSML_INVOKE_PREFIX) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { + const invokeNameStart = text[invokeAt + DSML_INVOKE_PREFIX.length]; + if (startsWithFolded(text, invokeAt, DSML_INVOKE_PREFIX) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; } - if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) { + if (invokeAt === text.length || prefixAtEnd(text, invokeAt, DSML_INVOKE_PREFIX)) { return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; } } diff --git a/src/adapters/codebuddy/tool-bridge.ts b/src/adapters/codebuddy/tool-bridge.ts new file mode 100644 index 00000000000..815121caaa5 --- /dev/null +++ b/src/adapters/codebuddy/tool-bridge.ts @@ -0,0 +1,597 @@ +import { createHash } from "node:crypto"; +import { + namespacedToolName, + toolChoiceToolPredicate, + type OcxParsedRequest, + type OcxTool, + type OcxToolChoice, +} from "../../types"; +import { stripResponsesOnlyEncryptedMarker } from "../responses-tool-schema"; + +export const CODEBUDDY_MCP_SERVER_NAME = "opencodex"; +export const CODEBUDDY_MCP_TOOL_PREFIX = `mcp__${CODEBUDDY_MCP_SERVER_NAME}__`; + +// 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({ + 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 + // streamed before message_stop; the capture-only MCP handler never returns, + // so every block must be observed before the parent terminates the turn. + // Each captured call is fully buffered under the per-call translator + // budget, so this bound also caps per-turn capture memory. + maxTurnToolCalls: 16, + maxNameBytes: 512, + maxDescriptionBytes: 64 * 1024, + maxSchemaBytes: 224 * 1024, + maxToolBytes: 256 * 1024, + maxCatalogBytes: 2 * 1024 * 1024, + maxSchemaDepth: 32, + maxSchemaNodes: 4_096, + maxPatternBytes: 8 * 1024, +}); + +// CodeBuddy 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 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"]); +const SCHEMA_MAP_KEYWORDS = [ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", +] as const; +const SCHEMA_VALUE_KEYWORDS = [ + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", +] as const; +const SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"] as const; +const NON_NEGATIVE_INTEGER_KEYWORDS = [ + "maxContains", + "maxItems", + "maxLength", + "maxProperties", + "minContains", + "minItems", + "minLength", + "minProperties", +] as const; +const FINITE_NUMBER_KEYWORDS = [ + "exclusiveMaximum", + "exclusiveMinimum", + "maximum", + "minimum", +] as const; +const STRING_KEYWORDS = [ + "$anchor", + "$comment", + "$id", + "$schema", + "$dynamicAnchor", + "contentEncoding", + "contentMediaType", + "description", + "format", + "title", +] as const; +const BOOLEAN_KEYWORDS = ["deprecated", "nullable", "readOnly", "uniqueItems", "writeOnly"] as const; +const textEncoder = new TextEncoder(); + +export interface CodeBuddyMcpToolDefinition { + name: string; + description: string; + inputSchema: Record; +} + +export interface CodeBuddyToolBridge { + tools: CodeBuddyMcpToolDefinition[]; + /** Exact nested-CLI-emitted MCP name -> Responses wire name. */ + emittedNameMap: Map; + requireToolCall: boolean; +} + +interface PreparedTool { + source: OcxTool; + wireName: string; + description: string; + inputSchema: Record; +} + +interface JsonCloneState { + active: WeakSet; + nodes: number; +} + +function utf8Bytes(value: string): number { + return textEncoder.encode(value).byteLength; +} + +function serializedBytes(value: unknown): number { + return utf8Bytes(JSON.stringify(value)); +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function defineDataProperty(target: Record, key: string, value: unknown): void { + // `__proto__` is a valid JSON Schema property name. Defining it as data keeps + // it from invoking Object.prototype's legacy setter while retaining a normal + // object prototype for downstream SDKs. + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +} + +function invalidJson(reason: string): never { + throw new Error(`invalid JSON value (${reason})`); +} + +/** + * Clone one schema into inert JSON data. A bounded recursive walk is safe here: + * the depth check happens before descent, and the resulting maximum call depth + * 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"); + state.nodes += 1; + if (state.nodes > CODEBUDDY_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"); + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) invalidJson("numbers must be finite"); + return value; + } + if (typeof value !== "object") invalidJson(`unsupported ${typeof value}`); + + const object = value as object; + if (state.active.has(object)) invalidJson("cycles are not allowed"); + state.active.add(object); + 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"); + + const keys = Reflect.ownKeys(value); + for (const key of keys) { + if (key === "length") continue; + if (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(key)) { + invalidJson("arrays may not have custom properties"); + } + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= value.length) { + invalidJson("array index is invalid"); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { + invalidJson("array entries must be enumerable data properties"); + } + } + if (keys.length - 1 !== value.length) invalidJson("sparse arrays are not allowed"); + + return value.map(entry => cloneBoundedJson(entry, depth + 1, state)); + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) invalidJson("objects must be plain records"); + const out: Record = {}; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") invalidJson("symbol keys are not allowed"); + if (hasUnpairedSurrogate(key)) invalidJson("property name contains an unpaired surrogate"); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { + invalidJson("object fields must be enumerable data properties"); + } + defineDataProperty(out, key, cloneBoundedJson(descriptor.value, depth + 1, state)); + } + return out; + } finally { + state.active.delete(object); + } +} + +function invalidSchema(reason: string): never { + throw new Error(reason); +} + +function assertStringArray(value: unknown, keyword: string, allowEmpty = true): string[] { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + invalidSchema(`${keyword} must be ${allowEmpty ? "an" : "a non-empty"} array of unique strings`); + } + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string" || seen.has(item)) { + invalidSchema(`${keyword} must be ${allowEmpty ? "an" : "a non-empty"} array of unique strings`); + } + seen.add(item); + } + return value as string[]; +} + +function assertSchema(value: unknown, keyword: string): void { + if (typeof value === "boolean") return; + if (!isRecord(value)) invalidSchema(`${keyword} must contain a JSON Schema`); + validateSchema(value); +} + +function validateSchemaMap(value: unknown, keyword: string, validatePatterns = false): void { + if (!isRecord(value)) invalidSchema(`${keyword} must be an object of JSON Schemas`); + for (const [name, schema] of Object.entries(value)) { + if (validatePatterns) validatePattern(name, `${keyword} key`); + assertSchema(schema, `${keyword}.${name}`); + } +} + +function validatePattern(value: unknown, keyword = "pattern"): void { + if (typeof value !== "string" || utf8Bytes(value) > CODEBUDDY_TOOL_LIMITS.maxPatternBytes) { + invalidSchema(`${keyword} must be a bounded regular-expression string`); + } + try { + new RegExp(value, "u"); + } catch { + invalidSchema(`${keyword} is not a valid regular expression`); + } +} + +function validateSchema(schema: Record): void { + if (Object.hasOwn(schema, "type")) { + const type = schema.type; + if (typeof type === "string") { + if (!JSON_SCHEMA_TYPES.has(type)) invalidSchema("type contains an unknown JSON Schema type"); + } else { + const types = assertStringArray(type, "type", false); + if (types.some(candidate => !JSON_SCHEMA_TYPES.has(candidate))) { + invalidSchema("type contains an unknown JSON Schema type"); + } + } + } + + for (const keyword of SCHEMA_MAP_KEYWORDS) { + if (Object.hasOwn(schema, keyword)) { + validateSchemaMap(schema[keyword], keyword, keyword === "patternProperties"); + } + } + for (const keyword of SCHEMA_VALUE_KEYWORDS) { + if (Object.hasOwn(schema, keyword)) assertSchema(schema[keyword], keyword); + } + for (const keyword of SCHEMA_ARRAY_KEYWORDS) { + if (!Object.hasOwn(schema, keyword)) continue; + const value = schema[keyword]; + if (!Array.isArray(value) || (keyword !== "prefixItems" && value.length === 0)) { + invalidSchema(`${keyword} must be an array of JSON Schemas${keyword === "prefixItems" ? "" : " with at least one entry"}`); + } + for (const entry of value) assertSchema(entry, keyword); + } + + if (Object.hasOwn(schema, "items")) { + const items = schema.items; + if (Array.isArray(items)) { + for (const entry of items) assertSchema(entry, "items"); + } else { + assertSchema(items, "items"); + } + } + if (Object.hasOwn(schema, "required")) assertStringArray(schema.required, "required"); + if (Object.hasOwn(schema, "enum")) { + if (!Array.isArray(schema.enum) || schema.enum.length === 0) invalidSchema("enum must be a non-empty array"); + } + if (Object.hasOwn(schema, "examples") && !Array.isArray(schema.examples)) { + invalidSchema("examples must be an array"); + } + + for (const keyword of NON_NEGATIVE_INTEGER_KEYWORDS) { + if (!Object.hasOwn(schema, keyword)) continue; + const value = schema[keyword]; + if (!Number.isSafeInteger(value) || (value as number) < 0) { + invalidSchema(`${keyword} must be a non-negative safe integer`); + } + } + for (const keyword of FINITE_NUMBER_KEYWORDS) { + if (!Object.hasOwn(schema, keyword)) continue; + if (typeof schema[keyword] !== "number" || !Number.isFinite(schema[keyword])) { + invalidSchema(`${keyword} must be a finite number`); + } + } + if (Object.hasOwn(schema, "multipleOf")) { + if (typeof schema.multipleOf !== "number" || !Number.isFinite(schema.multipleOf) || schema.multipleOf <= 0) { + invalidSchema("multipleOf must be a finite number greater than zero"); + } + } + + for (const keyword of STRING_KEYWORDS) { + if (Object.hasOwn(schema, keyword) && typeof schema[keyword] !== "string") { + invalidSchema(`${keyword} must be a string`); + } + } + for (const keyword of BOOLEAN_KEYWORDS) { + if (Object.hasOwn(schema, keyword) && typeof schema[keyword] !== "boolean") { + invalidSchema(`${keyword} must be a boolean`); + } + } + if (Object.hasOwn(schema, "pattern")) validatePattern(schema.pattern); + + for (const keyword of ["$ref", "$dynamicRef"] as const) { + if (!Object.hasOwn(schema, keyword)) continue; + const ref = schema[keyword]; + // External references hand resolution authority to the nested runtime and + // can turn a data-only catalog into network or filesystem access. Local + // JSON Pointer/anchor references retain recursive and reusable schemas. + if (typeof ref !== "string" || !ref.startsWith("#")) { + invalidSchema(`${keyword} must be a local fragment reference`); + } + } + + if (Object.hasOwn(schema, "$vocabulary")) { + if (!isRecord(schema.$vocabulary)) invalidSchema("$vocabulary must be an object"); + for (const enabled of Object.values(schema.$vocabulary)) { + if (typeof enabled !== "boolean") invalidSchema("$vocabulary values must be booleans"); + } + } + if (Object.hasOwn(schema, "dependentRequired")) { + if (!isRecord(schema.dependentRequired)) invalidSchema("dependentRequired must be an object"); + for (const [name, required] of Object.entries(schema.dependentRequired)) { + assertStringArray(required, `dependentRequired.${name}`); + } + } + if (Object.hasOwn(schema, "dependencies")) { + if (!isRecord(schema.dependencies)) invalidSchema("dependencies must be an object"); + for (const [name, dependency] of Object.entries(schema.dependencies)) { + if (Array.isArray(dependency)) assertStringArray(dependency, `dependencies.${name}`); + else assertSchema(dependency, `dependencies.${name}`); + } + } + + for (const [minimum, maximum] of [ + ["minContains", "maxContains"], + ["minItems", "maxItems"], + ["minLength", "maxLength"], + ["minProperties", "maxProperties"], + ] as const) { + if ( + typeof schema[minimum] === "number" + && typeof schema[maximum] === "number" + && schema[minimum] > schema[maximum] + ) { + invalidSchema(`${minimum} must not exceed ${maximum}`); + } + } + if ( + typeof schema.minimum === "number" + && typeof schema.maximum === "number" + && schema.minimum > schema.maximum + ) { + invalidSchema("minimum must not exceed maximum"); + } +} + +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`); + } + validateSchema(cloned); + if (Object.hasOwn(cloned, "type") && cloned.type !== "object") { + invalidSchema('the root type must be "object"'); + } + + 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`); + } + return stripped; +} + +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); +} + +function directCodeBuddyAlias(wireName: string): string | undefined { + return CODEBUDDY_TOOL_ALIAS_PATTERN.test(wireName) + && wireName.length <= MAX_CODEBUDDY_TOOL_ALIAS_CHARS + ? wireName + : 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); + 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 base = (cleaned || "tool").slice(0, maxBaseChars); + for (let salt = 0; salt <= CODEBUDDY_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."); +} + +/** Reserve direct names before hashing and sort the rest so request ordering cannot change aliases. */ +function codeBuddyToolAliases(wireNames: readonly string[]): Map { + const aliases = new Map(); + const used = new Set(); + for (const wireName of wireNames) { + const direct = directCodeBuddyAlias(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)); + return aliases; +} + +function requiresToolCall(choice: OcxToolChoice | undefined): boolean { + return choice === "required" + || (typeof choice === "object" && choice !== null && ( + "name" in choice || ("mode" in choice && choice.mode === "required") + )); +} + +function validateToolNamePart(value: unknown): value is string { + return typeof value === "string" + && value.length > 0 + && !hasUnpairedSurrogate(value) + && !INVALID_TOOL_NAME_PATTERN.test(value); +} + +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 (!validateToolNamePart(tool.name) || ( + tool.namespace !== undefined && !validateToolNamePart(tool.namespace) + )) { + throw new Error(`CodeBuddy 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 (seenWireNames.has(wireName)) { + throw new Error(`CodeBuddy 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.`); + } + 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.`); + } + + let inputSchema: Record; + try { + 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}.`); + } + return { source: tool, wireName, description, inputSchema }; +} + +function buildToolBridge(parsed: OcxParsedRequest): CodeBuddyToolBridge { + const allTools = parsed.context.tools ?? []; + if (!Array.isArray(allTools)) throw new Error("CodeBuddy tool catalog must be an array."); + + const choice = parsed.options.toolChoice; + const requireToolCall = requiresToolCall(choice); + // `none` is an authorization decision, so do not traverse or validate a + // catalog that the nested CLI must never see. Besides avoiding needless + // work, this prevents an unselected malformed or oversized definition from + // turning an explicitly tool-free request into a local adapter failure. + if (choice === "none") { + return { tools: [], emittedNameMap: new Map(), requireToolCall: false }; + } + + // Named and allowed-tools choices still need the complete identity view to + // reject ambiguous shorthand, but schema/description/size validation belongs + // only to definitions that can actually be advertised. `auto`/`required` + // select the whole catalog and therefore retain the original full boundary. + // Non-object entries have no selectable identity. Ignore them for a selective + // choice; if the choice names nothing else, the required-choice check below + // still fails closed. Unfiltered modes retain them so prepareTool rejects the + // malformed catalog as before. + const identityCatalog = typeof choice === "object" && choice !== null + ? allTools.filter(tool => tool !== null && typeof tool === "object") + : allTools; + const allows = toolChoiceToolPredicate(choice, identityCatalog); + const selected = identityCatalog + .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."); + } + if (selected.length > CODEBUDDY_TOOL_LIMITS.maxTools) { + throw new Error(`CodeBuddy tool catalog exceeds the ${CODEBUDDY_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 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.`); + } + return definition; + }); + if (serializedBytes(definitions) > CODEBUDDY_TOOL_LIMITS.maxCatalogBytes) { + throw new Error(`CodeBuddy tool catalog exceeds ${CODEBUDDY_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}`; + if (emittedNameMap.has(emittedName)) { + throw new Error("CodeBuddy tool catalog contains a colliding emitted alias."); + } + emittedNameMap.set(emittedName, preparedTool.wireName); + return definition; + }); + + return { tools, emittedNameMap, requireToolCall }; +} + +export function buildCodeBuddyToolBridge(parsed: OcxParsedRequest): CodeBuddyToolBridge { + return buildToolBridge(parsed); +} diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 4d38ac071e1..d4e5fd7298c 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -138,25 +138,72 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -/** Extract OpenCodex usage from a `result` frame's Anthropic-shaped usage object. */ -export function usageFromResult(message: StreamMessage): OcxUsage | undefined { - const usage = asRecord(message.usage); - if (!usage) return undefined; +/** Extract OpenCodex usage from the Anthropic-shaped usage record shared by frames and deltas. */ +function usageFromAnthropicShape(usage: Record): OcxUsage | undefined { const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; const cachedInputTokens = typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined; const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined; - if (inputTokens === 0 && outputTokens === 0 && cachedInputTokens === undefined) return undefined; + // A snapshot is zero-only when every counter is absent or zero. Testing only the cache-read + // field dropped a cache-creation-only snapshot (input/output 0 with, say, 200 cache-creation + // tokens), and a capture-only tool leg terminated at message_stop never sees a result frame + // that could carry those tokens instead, so the turn under-reported usage and cost. + const cacheReadTotal = cachedInputTokens ?? 0; + const cacheCreationTotal = cacheCreationInputTokens ?? 0; + if (inputTokens === 0 && outputTokens === 0 && cacheReadTotal === 0 && cacheCreationTotal === 0) { + return undefined; + } + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + ...(cacheReadTotal > 0 ? { cachedInputTokens: cacheReadTotal, cacheReadInputTokens: cacheReadTotal } : {}), + ...(cacheCreationTotal > 0 ? { cacheCreationInputTokens: cacheCreationTotal } : {}), + }; +} + +/** Extract OpenCodex usage from a `result` frame's Anthropic-shaped usage object. */ +export function usageFromResult(message: StreamMessage): OcxUsage | undefined { + const usage = asRecord(message.usage); + return usage ? usageFromAnthropicShape(usage) : undefined; +} + +/** + * Fold a pre-result usage snapshot into the running partial usage. + * + * `message_delta` and assistant-frame snapshots are cumulative per message, but a later snapshot + * can repeat or extend an earlier one, so each field keeps its maximum. The `result` frame stays + * authoritative for a text-only turn; partial state exists so a capture-only tool-bridge turn — + * which is terminated at `message_stop` before any result frame can arrive — still reports real + * token usage instead of zero. + */ +function mergePartialUsage(previous: OcxUsage | undefined, next: OcxUsage): OcxUsage { + if (!previous) return next; + const inputTokens = Math.max(previous.inputTokens, next.inputTokens); + const outputTokens = Math.max(previous.outputTokens, next.outputTokens); + const cacheRead = Math.max( + previous.cacheReadInputTokens ?? previous.cachedInputTokens ?? 0, + next.cacheReadInputTokens ?? next.cachedInputTokens ?? 0, + ); + const cacheCreation = Math.max(previous.cacheCreationInputTokens ?? 0, next.cacheCreationInputTokens ?? 0); return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, - ...(cachedInputTokens !== undefined ? { cachedInputTokens, cacheReadInputTokens: cachedInputTokens } : {}), - ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), + ...(cacheRead > 0 ? { cachedInputTokens: cacheRead, cacheReadInputTokens: cacheRead } : {}), + ...(cacheCreation > 0 ? { cacheCreationInputTokens: cacheCreation } : {}), }; } +/** Record one usage snapshot; absent, malformed, or zero-only snapshots leave state untouched. */ +function observePartialUsage(state: StreamParseState, value: unknown): void { + const usage = asRecord(value); + if (!usage) return; + const next = usageFromAnthropicShape(usage); + if (next) state.partialUsage = mergePartialUsage(state.partialUsage, next); +} + /** * Mutable per-turn parse state shared across frames of one stream (§十二). * Thinking and text states are strictly decoupled. @@ -166,6 +213,12 @@ export interface StreamParseState { sawPartialThinking: boolean; sawTerminalResult: boolean; openToolCallId?: string; + /** A `message_stop` stream event arrived: the assistant message is complete. */ + sawMessageStop?: boolean; + /** Completed tool_use content blocks observed in this stream. */ + completedToolCalls?: number; + /** Highest-seen usage snapshot from `message_delta`/assistant frames before a terminal result. */ + partialUsage?: OcxUsage; } /** @@ -188,7 +241,8 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa if (type === "assistant") { // Fallback path: a complete assistant message. Surface text and thinking independently // only when the partial delta stream did not already carry them (§十二). - const content = asRecord(message.message)?.content; + const messageRecord = asRecord(message.message); + const content = messageRecord?.content; if (Array.isArray(content)) { for (const block of content) { const part = asRecord(block); @@ -203,6 +257,7 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa } } } + observePartialUsage(state, messageRecord?.usage); return events; } @@ -281,8 +336,9 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt events.push({ type: "thinking_delta", thinking }); } } else if (deltaType === "input_json_delta") { - // Tool-input streaming. Inert while tools are disabled (Codex's catalog is not advertised), - // but parsed so the seam is ready and an unexpected frame never crashes. + // Tool-input streaming. Live for capture-only bridge turns, where the advertised MCP + // catalog makes the CLI emit real tool_use blocks; parsed unconditionally so a stray + // frame on a tools-disabled turn is ignored rather than crashing. const partial = asString(delta?.partial_json); if (partial && state.openToolCallId) events.push({ type: "tool_call_delta", arguments: partial }); } @@ -305,14 +361,63 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt if (eventType === "content_block_stop") { if (state.openToolCallId) { state.openToolCallId = undefined; + state.completedToolCalls = (state.completedToolCalls ?? 0) + 1; events.push({ type: "tool_call_end" }); } return events; } + if (eventType === "message_stop") { + state.sawMessageStop = true; + return events; + } + + if (eventType === "message_start") { + // Anthropic-shaped streams report input tokens on `message_start.message.usage` and output + // tokens later on `message_delta.usage`. A capture-only tool leg is terminated at + // `message_stop`, so without this branch the synthesized done(tool_use) undercounts input + // tokens whenever the CLI puts them here (and `message_stop` arrives before any assistant + // fallback frame that would otherwise carry them). + const messageRecord = asRecord(event.message); + observePartialUsage(state, messageRecord?.usage); + return events; + } + + if (eventType === "message_delta") { + // Pre-result usage snapshots: a capture-only tool-bridge turn ends at message_stop with no + // result frame, so these snapshots are the only token accounting that leg will ever see. + observePartialUsage(state, event.usage); + return events; + } + return events; } +/** + * Validate a `system/init` frame against an active capture-only tool bridge. + * + * With the bridge armed, the CLI must report exactly the bridge's MCP server as connected: a + * missing or failed server means the model never saw the advertised catalog, so the turn fails + * closed instead of silently degrading to a text-only answer. + */ +export function toolBridgeInitError(message: StreamMessage, serverName: string): string | undefined { + if (message.type !== "system" || message.subtype !== "init") return undefined; + const servers = message.mcp_servers; + if (!Array.isArray(servers) || servers.length !== 1) { + return "Coding-agent system/init reported an unexpected MCP server set for the tool bridge."; + } + const server = servers[0]; + if ( + !server + || typeof server !== "object" + || server.name !== serverName + || server.status !== "connected" + ) { + return `Coding-agent system/init did not report the ${serverName} MCP server as connected.`; + } + return undefined; +} + /** One content part on the stream-json input wire (Anthropic message shape). */ type WireContentPart = Record; diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index f13b1ff0ea3..413557b994d 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -1,9 +1,20 @@ import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; import { commandInvocation } from "../../lib/win-exec"; import { modelRecordValue } from "../../reasoning-effort"; import type { IncomingMeta } from "../base"; -import { buildConversationInput, CodingAgentProtocolError, mapStreamMessageToEvents, projectedHistoryCharLimit, readJsonLines, type StreamParseState } from "./protocol"; +import { + buildConversationInput, + CodingAgentProtocolError, + mapStreamMessageToEvents, + projectedHistoryCharLimit, + readJsonLines, + toolBridgeInitError, + type StreamParseState, +} from "./protocol"; import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile"; /** Injectable spawn for tests; production uses node:child_process. */ @@ -26,6 +37,8 @@ export interface CodingAgentDeps { platform?: NodeJS.Platform; /** Test seam for terminating a Windows CLI and all descendants. */ killWindowsProcessTree?: KillWindowsProcessTreeFn; + /** Test seam for a catalog/config write failure after private bridge-directory creation. */ + writeToolBridgeFile?: typeof writeFile; } const DEFAULT_TIMEOUT_MS = 300_000; @@ -88,9 +101,37 @@ export interface CodingAgentTurnInput { buildArgs: (profile: CodingAgentProviderProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig) => string[]; /** Family-specific scoped env builder (credential + region switch on top of baseScopedEnv). */ buildEnv: (profile: CodingAgentProviderProfile, apiKey: string) => Record; + /** + * Opt-in capture-only tool bridge. When present with a non-empty catalog, the turn writes a + * validated catalog plus an MCP config to a private temp dir, passes `--mcp-config` (with exact + * `--allowedTools`) alongside the family's tools-disabled args, translates captured tool_use + * names back to request wire names, and terminates the process tree at `message_stop` because + * the capture-only MCP handler intentionally never answers. Execution stays with the client. + */ + toolBridge?: CodingAgentToolBridgeInput; deps: CodingAgentDeps; } +/** Opt-in capture-only tool bridge for one coding-agent CLI turn. */ +export interface CodingAgentToolBridgeInput { + /** MCP server name advertised to the CLI; tool_use blocks render it as `mcp____`. */ + serverName: string; + /** Absolute path of the capture-only MCP server module, run with the serving runtime. */ + serverModulePath: string; + /** Validated tool catalog advertised over ListTools; the server never executes a call. */ + tools: ReadonlyArray<{ name: string; description: string; inputSchema: Record }>; + /** CLI-emitted tool name (`mcp____`) to the request's wire tool name. */ + emittedNameMap: Map; + /** Captured tool_use blocks accepted in one assistant message. */ + maxTurnToolCalls: number; + /** + * The request's `tool_choice` requires a tool call (`required`, or a named selection). + * The nested CLI has no documented force-tool flag, so this is enforced locally: a + * terminal text result on a required turn fails closed instead of silently succeeding. + */ + requireToolCall?: boolean; +} + /** * Run one headless coding-agent CLI turn as an OpenCodex `runTurn` (§七/§三十). * @@ -153,7 +194,62 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise undefined); + return; + } + } + const args = buildArgs(profile, parsed, provider); + if (toolBridge && toolBridgeMcpConfigPath) { + // Exact names close the wildcard domain; --strict-mcp-config (family args) keeps user + // servers out, so the capture server is the only capability this turn can reach. + args.push("--allowedTools", [...toolBridge.emittedNameMap.keys()].join(","), "--mcp-config", toolBridgeMcpConfigPath); + } const env = buildEnv(profile, apiKey); const invocation = commandInvocation(binary, args, platform, { env }); @@ -174,6 +270,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise undefined); return; } @@ -218,6 +317,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { try { child.kill("SIGKILL"); } catch { /* already gone */ } @@ -253,6 +355,12 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise | undefined; const state: StreamParseState = { sawPartialText: false, sawPartialThinking: false, @@ -278,13 +386,183 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise toolBridge.maxTurnToolCalls) { + emitOnce({ + type: "error", + message: `Coding-agent CLI returned more than the ${toolBridge.maxTurnToolCalls}-tool-call turn limit.`, + status: 502, + errorType: "upstream_error", + code: "tool_call_limit", + retryable: false, + }); + failClosed = true; + kill(); + break; + } + const wireName = toolBridge.emittedNameMap.get(event.name); + if (wireName === undefined) { + emitOnce({ + type: "error", + message: "Coding-agent CLI called a tool outside the isolated catalog.", + status: 502, + errorType: "upstream_error", + code: "undeclared_tool_call", + retryable: false, + }); + failClosed = true; + kill(); + break; + } + emitOnce({ ...event, name: wireName }); + continue; + } + if ( + toolBridge?.requireToolCall === true + && !terminalEmitted + && event.type === "done" + && event.stopReason !== "tool_use" + && (state.completedToolCalls ?? 0) === 0 + ) { + // `tool_choice: required|named` on a bridge turn: a text-only terminal result must not + // become a successful completion the client can accept. The capture-only bridge has no + // way to force the nested CLI, so fail closed with the same stable error shape the + // other bridge contract violations use. + emitOnce({ + type: "error", + message: "CodeBuddy finished without calling the required tool.", + status: 502, + errorType: "upstream_error", + code: "tool_call_required", + retryable: false, + }); + failClosed = true; + kill(); + break; + } + if ( + toolBridge + && !terminalEmitted + && event.type === "done" + && toolCallStarts > 0 + && (state.completedToolCalls ?? 0) !== toolCallStarts + ) { + // A terminal result that arrives while a captured tool call is still open must not + // become a successful completion the client can accept. The message_stop check after + // the event loop cannot cover this path: the CLI normally parks on the + // never-answering capture server, but a stream that delivers the result frame + // without (or before) message_stop emits done here, and started-but-unfinished + // calls slipped through as successful turns. + emitOnce({ + type: "error", + message: "Coding-agent CLI ended with an incomplete tool call.", + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + failClosed = true; + kill(); + break; + } + if ( + toolBridge + && !terminalEmitted + && event.type === "done" + && toolCallStarts > 0 + && (state.completedToolCalls ?? 0) === toolCallStarts + ) { + // Every captured call completed and the CLI settled with a successful result before + // message_stop (instead of parking on the never-answering capture server). Emitting + // this done(stop) now would end the turn as a text completion and skip the + // synthesized done(tool_use) the client contract expects. Defer it: message_stop + // synthesis emits the terminal event with this frame's usage, and a stream that + // ends without message_stop fails closed with protocol_error below. + deferredResultDone = event; + continue; + } emitOnce(event.type === "error" ? { ...event, message: redactSecrets(event.message, profile.tokenEnv, apiKey) } : event); } + if (failClosed) break; + if ( + toolBridge + && !terminalEmitted + && state.sawMessageStop + && toolCallStarts > 0 + && (state.completedToolCalls ?? 0) !== toolCallStarts + ) { + emitOnce({ + type: "error", + message: "Coding-agent CLI ended with an incomplete tool call.", + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + kill(); + break; + } + if (toolBridge && !terminalEmitted && state.sawMessageStop && (state.completedToolCalls ?? 0) > 0) { + // No init re-check here: a completed call implies a tool_call_start was mapped, and the + // arrival-time gate above already refuses any start that lands before the handshake. + // The capture-only MCP handler never answers, so the CLI parks after message_stop. + // The completed tool_use blocks are this turn's structured output: end the leg here + // and terminate the tree; the client executes, and the next request continues. + // Pre-result usage snapshots keep this terminated leg accountable: no result frame + // ever arrives for a turn parked on the never-answering capture server. + const terminalUsage = deferredResultDone?.usage ?? state.partialUsage; + emitOnce({ + type: "done", + stopReason: "tool_use", + endTurn: false, + ...(terminalUsage ? { usage: terminalUsage } : {}), + }); + kill(); + break; + } if (terminalEmitted) break; } } catch (err) { @@ -296,6 +574,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise undefined); + } } // Reap the process so no zombie is left behind (§三十): wait for the real `close`, and @@ -353,6 +634,15 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise= "0" && first <= "9") + || first === "t" + || first === "f" + || first === "n"; +} + export function adapterFailureFromEvent(event: Extract): { httpStatus: number; error: OcxErrorPayload } { const message = redactSecretString(event.message); if (event.status === undefined && event.errorType === undefined && event.code === undefined) { diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index a1f5a0fb8a4..5d1ec51ab37 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -47,7 +47,7 @@ import { type TranslatorBudget, type TranslatorBufferKind, } from "../lib/translator-budget"; -import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsUsable, uuid, webSearchAction } from "./internal"; +import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsCouldBeJson, toolCallArgumentsUsable, uuid, webSearchAction } from "./internal"; import type { OutputItem, StringChunks } from "./internal"; function sseEvent(name: string, data: Record): string { @@ -1055,10 +1055,17 @@ export function bridgeToResponsesSSE( currentToolCall.callId, )); if (!currentToolCall.freeform && !currentToolCall.toolSearch) { - emit("response.function_call_arguments.delta", { - item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, - delta: event.arguments, - }); + // Hold fragments whose accumulated buffer can never parse as JSON. Fragments + // already streamed are retained by the client as history even when the item + // fails at completion (the poisoned-replay loop behind inbound "non-JSON + // arguments" warnings); holding costs nothing for healthy streams because the + // completed item still carries the full arguments. + if (toolCallArgumentsCouldBeJson(currentToolCall.args)) { + emit("response.function_call_arguments.delta", { + item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, + delta: event.arguments, + }); + } } if (currentToolCall.freeform && !currentToolCall.codeModeHelperName) { // `progressiveFreeformInput` holds while the buffer is still an ambiguous prefix diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 3dc0ff10b46..9b19ae45d38 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1321,9 +1321,10 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ // private console endpoint — the approach closed in #687 and left in draft in #2244. // baseUrl is the canonical region identity: the adapter fails closed if it is overridden, so a // global key is never sent to the CN environment (that is the separate `codebuddy-cn` entry). - // v1 runs tools-disabled so Codex keeps tool ownership; this provider is text/reasoning only - // until the control-protocol tool bridge lands (see docs). Free/trial/promotional/subscription - // credits draw from the same official API-key pool. Requires the CLI: `npm i -g @tencent-ai/codebuddy-code`. + // The CLI always runs tools-disabled; a capture-only MCP bridge advertises the request's + // Codex tool catalog, so approval, sandboxing, and execution stay with the client. + // Free/trial/promotional/subscription credits draw from the same official API-key pool. + // Requires the CLI: `npm i -g @tencent-ai/codebuddy-code`. // GOVERNANCE: whether routing this vendor automation surface behind a proxy for a third-party // agent satisfies CodeBuddy's AUP is an open question flagged for maintainer security review. id: "codebuddy", @@ -1343,7 +1344,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, modelReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, modelDefaultReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, - note: "Official CodeBuddy Code CLI (Tencent Cloud), global/public environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy-cn. v1 disables CLI tools (--tools \"\") so Codex retains tool ownership: text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", + note: "Official CodeBuddy Code CLI (Tencent Cloud), global/public environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy-cn. 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. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", }, { // Official CodeBuddy Code CLI provider, CHINA / `internal` environment. Identical adapter and @@ -1368,7 +1369,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ modelReasoningEfforts: CODEBUDDY_CN_MODEL_REASONING_EFFORTS, modelDefaultReasoningEfforts: CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, noVisionModels: CODEBUDDY_CN_NO_VISION_MODELS, - note: "Official CodeBuddy Code CLI (Tencent Cloud), China/internal environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy (Global); credentials are never exchanged across regions. v1 disables CLI tools (--tools \"\"): text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", + note: "Official CodeBuddy Code CLI (Tencent Cloud), China/internal environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy (Global); credentials are never exchanged across regions. 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. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", }, { id: "stepfun", diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 51bb36f5245..80ce2cb0bab 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -42,6 +42,28 @@ function replayThoughtSignatureMetadata( return signature ? { google: { thoughtSignature: signature } } : undefined; } +/** + * Repair one bounded inbound-history corruption: a JSON object literal that lost exactly + * its opening brace (observed as `code":"…}` after `{"` went missing, taking the key's + * opening quote with it). Only a text that ends with `}` and parses into an object once + * the brace is restored counts — anything looser keeps the tolerated-{} fallback so + * freeform text that merely resembles JSON is never rewritten. + */ +function repairJsonObjectEnvelope(text: string): Record | undefined { + if (!text.endsWith("}")) return undefined; + // A body that still opens with a quoted key lost only `{`; the observed shape lost + // `{"` together, taking the key's opening quote with it. Both restorations must parse + // into an object, so freeform text that merely resembles JSON is never rewritten. + const candidate = text.startsWith('"') ? `{${text}` : `{"${text}`; + try { + const parsed: unknown = JSON.parse(candidate); + if (isObj(parsed)) return parsed; + } catch { + /* fall through to the tolerated-{} path */ + } + return undefined; +} + function ensureAssistantPlaceholder(messages: OcxMessage[], modelId: string, now: number): OcxAssistantMessage { @@ -333,7 +355,16 @@ export function parseRequest( const parsed: unknown = JSON.parse(rawArgs); if (isObj(parsed)) args = parsed; } catch { - console.warn(`[parser] function_call ${call.call_id} has non-JSON arguments; defaulting to {}`); + // One observed serialization corruption loses exactly the JSON object's opening + // brace; the closed envelope is tight enough to repair back into a call the + // routed model can still see and retry, instead of replaying {} forever. + const repaired = repairJsonObjectEnvelope(rawArgs); + if (repaired === undefined) { + console.warn(`[parser] function_call ${call.call_id} has non-JSON arguments; defaulting to {}`); + } else { + args = repaired; + console.warn(`[parser] function_call ${call.call_id} arguments lost the JSON opening brace; repaired from history`); + } } } // Do NOT map Responses item `id` (fc_/ctc_/…) onto `thoughtSignature`. That field is diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 132265964a9..e59c19b8e02 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,5 +1,10 @@ # Providers And Adapters +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. + OrcaRouter key exchange uses the shared raw-byte reader before returning a durable key. Its 64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow the [bounded ingestion contract](transports/inventory.md#bounded-response-ingestion-and-orcarouter-login). diff --git a/tests/adapters/bridge.test.ts b/tests/adapters/bridge.test.ts index 3737538a442..793b1e32eda 100644 --- a/tests/adapters/bridge.test.ts +++ b/tests/adapters/bridge.test.ts @@ -675,6 +675,36 @@ describe("Responses bridge reasoning and usage parity", () => { expect(frames.some(f => f.event === "response.function_call_arguments.done")).toBe(false); }); + test("holds function-call argument fragments that can never parse, failing the item clean", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_6c903fcfec9947a8b7aff270", name: "js" }, + // A coding-agent stream that already lost its leading `{"` (observed 260921): + // the fragments can never assemble into parseable JSON. + { type: "tool_call_delta", arguments: 'code":"let log = [];"' }, + { type: "tool_call_delta", arguments: ',"timeout_ms":90000}' }, + { type: "tool_call_end" }, + { type: "done" }, + ]), "codebuddy-cn/hy4-preview-f")); + + expect(frames.some(f => f.event === "response.function_call_arguments.delta")).toBe(false); + const failed = frames.find(f => f.event === "response.failed")?.data.response as Record; + const failure = failed?.error as Record | undefined; + expect(String(failure?.message)).toContain("malformed tool call arguments"); + }); + + test("still streams healthy function-call argument deltas unchanged", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_ok", name: "js" }, + { type: "tool_call_delta", arguments: '{"code":"' }, + { type: "tool_call_delta", arguments: 'let x = 1"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ]), "codebuddy-cn/hy4-preview-f")); + + const deltas = frames.filter(f => f.event === "response.function_call_arguments.delta").map(f => f.data.delta); + expect(deltas.join("")).toBe('{"code":"let x = 1"}'); + }); + test("repairs a complete decorated top-level apply_patch payload", () => { const body = `*** Begin Patch *** *** Update File: README.md diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 051bb706424..a79e9543117 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -290,7 +290,11 @@ "cline-writer.test.ts": "clients", "closed-pr-branch-cleanup.test.ts": "ci-workflows", "codebuddy-adapter.test.ts": "providers", + "codebuddy-live-acceptance.test.ts": "providers", + "codebuddy-mcp-server.test.ts": "providers", "codebuddy-protocol.test.ts": "providers", + "codebuddy-tool-bridge-turn.test.ts": "providers", + "codebuddy-tool-bridge.test.ts": "providers", "codex-account-delete-atomicity.test.ts": "codex-integration", "codex-account-label.test.ts": "codex-integration", "codex-account-mode-state.test.ts": "gui", diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index 0f96797ceaa..aafde43c0a4 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -408,6 +408,39 @@ describe("codebuddy runTurn streams a headless turn", () => { ]); }); + test("scans a large multiline delta in time linear in its length", () => { + const run = (lines: number): { events: AdapterEvent[]; elapsed: number } => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "a\n".repeat(lines); + const startedAt = performance.now(); + guarded({ type: "text_delta", text: answer }); + return { events, elapsed: performance.now() - startedAt }; + }; + + const baseline = run(40_000); + const scaled = run(160_000); + + // Four times the input must stay near 4x cost; a scan re-walking its suffix could not fit. + expect(scaled.elapsed).toBeLessThan(Math.max(baseline.elapsed * 8, 250)); + expect(baseline.events).toEqual([{ type: "text_delta", text: "a\n".repeat(40_000) }]); + expect(scaled.events).toEqual([{ type: "text_delta", text: "a\n".repeat(160_000) }]); + }); + + test("still refuses scaffolding after a code point that expands when lowercased", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + // İ (U+0130) lowercases to two code units, so folded-text offsets no longer match `text`. + guarded({ + type: "text_delta", + text: "note İ here\n<||DSML|| calls>\n<||DSML|| invoke name=\"exec\">private-body", + }); + + expect(events.at(-1)).toEqual(expect.objectContaining({ type: "error", code: "vendor_scaffold_detected" })); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + test("delivers quoted and inline-code DSML literals unchanged", () => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event)); diff --git a/tests/providers/codebuddy-live-acceptance.test.ts b/tests/providers/codebuddy-live-acceptance.test.ts new file mode 100644 index 00000000000..dba23bc544a --- /dev/null +++ b/tests/providers/codebuddy-live-acceptance.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, test } from "bun:test"; +import { + abortable, + AcceptanceFailure, + argumentsMatch, + assertLiveOptIn, + assertLoopbackListener, + liveAcceptanceApiKey, + liveAcceptanceCliPath, + liveAcceptanceModel, + liveAcceptanceRegion, + readResponseStream, + runAcceptanceScenario, + syntheticAcceptanceResult, +} from "../../scripts/codebuddy-live-acceptance"; + +function codeOf(fn: () => unknown): string { + try { + fn(); + } catch (error) { + if (error instanceof AcceptanceFailure) return error.code; + throw error; + } + return "no_failure"; +} + +async function rejectedCode(work: Promise): Promise { + try { + await work; + } catch (error) { + if (error instanceof AcceptanceFailure) return error.code; + throw error; + } + return "no_failure"; +} + +function frame(type: string, payload: unknown): string { + // Real Responses SSE carries the event type inside the data payload as well; + // the parser cross-checks the event line against data.type. + const body = payload && typeof payload === "object" && !Array.isArray(payload) + ? { type, ...(payload as Record) } + : payload; + return "event: " + type + "\ndata: " + JSON.stringify(body) + "\n\n"; +} + +function sse(frames: string): Response { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(frames)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + }); +} + +function toolTurn(id: string, callId: string, name: string, args: Record): string { + const serialized = JSON.stringify(args); + const midpoint = Math.max(1, Math.floor(serialized.length / 2)); + return [ + frame("response.function_call_arguments.delta", { item_id: id, delta: serialized.slice(0, midpoint) }), + frame("response.function_call_arguments.delta", { item_id: id, delta: serialized.slice(midpoint) }), + frame("response.function_call_arguments.done", { item_id: id, arguments: serialized }), + frame("response.completed", { + response: { + id: "resp_for_" + callId, + status: "completed", + model: "kimi-k2.5", + usage: { input_tokens: 20, output_tokens: 7, total_tokens: 27 }, + output: [{ type: "function_call", id, call_id: callId, name, arguments: serialized }], + }, + }), + "data: [DONE]\n\n", + ].join(""); +} + +function textTurn(id: string, text: string): string { + const midpoint = Math.max(1, Math.floor(text.length / 2)); + return [ + frame("response.output_text.delta", { item_id: id, content_index: 0, delta: text.slice(0, midpoint) }), + frame("response.output_text.delta", { item_id: id, content_index: 0, delta: text.slice(midpoint) }), + frame("response.output_text.done", { item_id: id, content_index: 0, text }), + frame("response.completed", { + response: { + id: "resp_for_text", + status: "completed", + model: "kimi-k2.5", + output: [{ type: "message", id, content: [{ type: "output_text", text }] }], + }, + }), + "data: [DONE]\n\n", + ].join(""); +} + +interface ScenarioRequest { body: Record; turnId: string | null } + +function scenarioFetch(options: { omitUsage?: boolean; usageInputOnly?: boolean; omitDone?: boolean } = {}): { + fetch: (input: URL, init: RequestInit) => Promise; + requests: ScenarioRequest[]; +} { + const requests: ScenarioRequest[] = []; + const fetch = async (_input: URL, init: RequestInit): Promise => { + const body = JSON.parse(String(init.body)) as Record; + const metadata = new Headers(init.headers).get("x-codex-turn-metadata"); + requests.push({ body, turnId: metadata }); + if (!body.previous_response_id) { + const raw = toolTurn("fc_lookup", "call_lookup", "lookup_inventory", { sku: "TEST-123" }); + if (options.omitUsage) return sse(raw.replace(/"usage":\{[^}]*\},/, "")); + if (options.usageInputOnly) { + return sse(raw.replace(/"usage":\{[^}]*\},/, '"usage":{"input_tokens":20,"output_tokens":0,"total_tokens":20},')); + } + return sse(raw); + } + if (body.previous_response_id === "resp_for_call_lookup") { + return sse(toolTurn("fc_reserve", "call_reserve", "reserve_inventory", { sku: "TEST-123", quantity: 2 })); + } + const finalTurn = textTurn("msg_final", "R-42"); return sse(options.omitDone ? finalTurn.replace("data: [DONE]\n\n", "") : finalTurn); + }; + return { fetch, requests }; +} + +describe("CodeBuddy live acceptance harness", () => { + test("accepts exact selectors without allowing provider prefixes or CLI arguments", () => { + expect(liveAcceptanceModel({})).toBe("kimi-k2.5"); + expect(liveAcceptanceModel({ CODEBUDDY_LIVE_MODEL: "kimi-k3" })).toBe("kimi-k3"); + for (const model of ["", "codebuddy/kimi-k3", "--model", "kimi-k3 extra", "x".repeat(65)]) { + expect(codeOf(() => liveAcceptanceModel({ CODEBUDDY_LIVE_MODEL: model }))).toBe("invalid_model_selector"); + } + }); + + test("region and key gates admit only the two presets and a present key", () => { + expect(liveAcceptanceRegion({})).toBe("global"); + expect(liveAcceptanceRegion({ CODEBUDDY_LIVE_REGION: "cn" })).toBe("cn"); + for (const region of ["", "global-cn", "internal", "GLOBAL"]) { + expect(codeOf(() => liveAcceptanceRegion({ CODEBUDDY_LIVE_REGION: region }))).toBe("invalid_region"); + } + expect(codeOf(() => liveAcceptanceApiKey({}))).toBe("explicit_api_key_required"); + expect(codeOf(() => liveAcceptanceApiKey({ CODEBUDDY_LIVE_API_KEY: " " }))).toBe("explicit_api_key_required"); + expect(liveAcceptanceApiKey({ CODEBUDDY_LIVE_API_KEY: " ck_key " })).toBe("ck_key"); + }); + + test("validates the selected model on the returned completion", async () => { + const frames = textTurn("msg_probe", "OK").replaceAll("kimi-k2.5", "kimi-k3"); + const result = await readResponseStream(sse(frames), new AbortController().signal, "kimi-k3"); + expect(result.response.model).toBe("kimi-k3"); + expect(await rejectedCode(readResponseStream(sse(frames), new AbortController().signal))) + .toBe("invalid_completed_response"); + }); + + test("requires an explicit opt-in, an absolute CLI path, and a key outside the test preload", () => { + const optedIn = { + CODEBUDDY_LIVE_TEST: "1", + CODEBUDDY_LIVE_CLI_PATH: "/opt/codebuddy/bin", + CODEBUDDY_LIVE_API_KEY: "ck_key", + }; + expect(codeOf(() => assertLiveOptIn({}))).toBe("explicit_opt_in_required"); + expect(codeOf(() => assertLiveOptIn({ CODEBUDDY_LIVE_TEST: "1", OCX_TEST_PRELOAD_PID: "1" }))) + .toBe("test_preload_not_supported"); + expect(codeOf(() => assertLiveOptIn({ CODEBUDDY_LIVE_TEST: "1", BUN_TEST_WORKER_ID: "0" }))) + .toBe("test_preload_not_supported"); + expect(codeOf(() => assertLiveOptIn({ ...optedIn, CODEBUDDY_LIVE_CLI_PATH: "relative/codebuddy" }))) + .toBe("cli_path_must_be_absolute"); + expect(codeOf(() => assertLiveOptIn({ CODEBUDDY_LIVE_TEST: "1", CODEBUDDY_LIVE_API_KEY: "ck_key" }))) + .toBe("explicit_cli_path_required"); + expect(codeOf(() => assertLiveOptIn({ + CODEBUDDY_LIVE_TEST: "1", CODEBUDDY_LIVE_CLI_PATH: "/opt/codebuddy/bin", + }))).toBe("explicit_api_key_required"); + expect(codeOf(() => assertLiveOptIn({ ...optedIn, CODEBUDDY_LIVE_REGION: "internal" }))).toBe("invalid_region"); + expect(codeOf(() => assertLiveOptIn(optedIn))).toBe("no_failure"); + expect(codeOf(() => liveAcceptanceCliPath({ CODEBUDDY_LIVE_CLI_PATH: " " }))) + .toBe("explicit_cli_path_required"); + expect(liveAcceptanceCliPath({ CODEBUDDY_LIVE_CLI_PATH: " /opt/codebuddy/bin " })).toBe("/opt/codebuddy/bin"); + }); + + test("labels synthetic success without claiming native-client acceptance", () => { + expect(syntheticAcceptanceResult(true)).toEqual({ passed: true, + code: "synthetic_three_turn_streaming_passed", scope: "synthetic-adapter", codexClientExecuted: false }); + expect(syntheticAcceptanceResult(false, new Error("private response"))).toEqual({ passed: false, + code: "acceptance_failed", scope: "synthetic-adapter", codexClientExecuted: false }); + expect(syntheticAcceptanceResult(false, undefined).passed).toBe(false); + }); + + test("admits only a positive ephemeral loopback listener", () => { + const at = (port: number, hostname = "127.0.0.1") => () => assertLoopbackListener({ + port, + hostname, + url: new URL("http://" + hostname + ":" + port), + }); + expect(codeOf(at(10100))).toBe("unsafe_listener_port"); + expect(codeOf(at(0))).toBe("unsafe_listener_port"); + expect(codeOf(at(43210, "0.0.0.0"))).toBe("unsafe_listener_address"); + expect(codeOf(at(43210, "example.internal"))).toBe("unsafe_listener_address"); + expect(codeOf(at(43210))).toBe("no_failure"); + expect(codeOf(() => assertLoopbackListener({ + port: 43210, + hostname: "127.0.0.1", + url: new URL("http://user:pass@127.0.0.1:43210"), + }))).toBe("unsafe_listener_address"); + }); + + test("abortable rejects before an aborted operation settles", async () => { + const controller = new AbortController(); + controller.abort(); + expect(await rejectedCode(abortable(new Promise(() => {}), controller.signal))) + .toBe("acceptance_aborted"); + }); + + test("matches tool arguments exactly by key set and value", () => { + expect(argumentsMatch("{\"sku\":\"TEST-123\"}", { sku: "TEST-123" })).toBe(true); + expect(argumentsMatch("{\"sku\":\"TEST-124\"}", { sku: "TEST-123" })).toBe(false); + expect(argumentsMatch("{\"sku\":\"TEST-123\",\"extra\":1}", { sku: "TEST-123" })).toBe(false); + expect(argumentsMatch("not json", { sku: "TEST-123" })).toBe(false); + expect(argumentsMatch({ sku: "TEST-123" }, { sku: "TEST-123" })).toBe(false); + }); + + test("rejects torn, failed, and completion-less streams", async () => { + const signal = new AbortController().signal; + expect(await rejectedCode(readResponseStream( + sse(frame("response.completed", { response: { id: "x", status: "completed", model: "kimi-k2.5", output: [] } }).slice(0, 10)), + signal, + ))).toBe("truncated_sse_frame"); + expect(await rejectedCode(readResponseStream( + sse(frame("error", { message: "boom" })), + signal, + ))).toBe("response_failed"); + expect(await rejectedCode(readResponseStream( + sse(frame("response.output_text.delta", { item_id: "m", content_index: 0, delta: "hi" }) + "data: [DONE]\n\n"), + signal, + ))).toBe("premature_or_duplicate_done"); + expect(await rejectedCode(readResponseStream( + new Response("ok", { status: 500, headers: { "content-type": "text/event-stream" } }), + signal, + ))).toBe("unexpected_http_status"); + }); + + test("runs the three-turn scenario with one shared turn id and chained state", async () => { + const { fetch, requests } = scenarioFetch(); + + await runAcceptanceScenario( + new URL("http://127.0.0.1:43210"), + new AbortController().signal, + fetch, + ); + + expect(requests).toHaveLength(3); + // The real Codex client reuses one stable user turn_id across every + // Responses request in a turn and sends parallel_tool_calls as permission. + expect(requests[0]!.turnId).toBeTruthy(); + expect(new Set(requests.map(request => request.turnId)).size).toBe(1); + expect(JSON.parse(requests[0]!.turnId!)).toMatchObject({ turn_id: expect.any(String) }); + for (const request of requests) { + expect(request.body.model).toBe("codebuddy/kimi-k2.5"); + expect(request.body.stream).toBe(true); + expect(request.body.parallel_tool_calls).toBe(true); + } + expect(requests[1]!.body.previous_response_id).toBe("resp_for_call_lookup"); + expect(requests[1]!.body.input).toMatchObject([ + { type: "function_call_output", call_id: "call_lookup" }, + { type: "message", role: "user" }, + ]); + expect(requests[2]!.body.previous_response_id).toBe("resp_for_call_reserve"); + expect(requests[2]!.body.input).toMatchObject([ + { type: "function_call_output", call_id: "call_reserve" }, + { type: "message", role: "user" }, + ]); + }); + + test("routes the scenario through the selected region's provider prefix", async () => { + const { fetch, requests } = scenarioFetch(); + await runAcceptanceScenario(new URL("http://127.0.0.1:43210"), new AbortController().signal, fetch, "kimi-k2.5", "codebuddy-cn"); + for (const request of requests) expect(request.body.model).toBe("codebuddy-cn/kimi-k2.5"); + }); + + test("a tool leg without reported usage fails the scenario", async () => { + const { fetch } = scenarioFetch({ omitUsage: true }); + expect(await rejectedCode(runAcceptanceScenario( + new URL("http://127.0.0.1:43210"), + new AbortController().signal, + fetch, + ))).toBe("usage_missing"); + }); + + test("a tool leg with zero output tokens fails the scenario", async () => { + // The single-field check this replaced accepted a report where the tool leg's output usage + // was lost; both directions of the partial-usage fold must be positive. + const { fetch } = scenarioFetch({ usageInputOnly: true }); + expect(await rejectedCode(runAcceptanceScenario( + new URL("http://127.0.0.1:43210"), + new AbortController().signal, + fetch, + ))).toBe("usage_zero"); + }); + + test("a stream ending after completion without [DONE] fails acceptance", async () => { + const { fetch } = scenarioFetch({ omitDone: true }); + expect(await rejectedCode(runAcceptanceScenario( + new URL("http://127.0.0.1:43210"), + new AbortController().signal, + fetch, + ))).toBe("missing_done"); + }); + + test("a mention of the expected marker is not a semantic pass", async () => { + let count = 0; + const mockFetch = async () => { + count++; + if (count === 1) return sse(toolTurn("fc_lookup", "call_lookup", "lookup_inventory", { sku: "TEST-123" })); + if (count === 2) return sse(toolTurn("fc_reserve", "call_reserve", "reserve_inventory", { sku: "TEST-123", quantity: 2 })); + return sse(textTurn("msg_final", "R-42 is not available.")); + }; + expect(await rejectedCode(runAcceptanceScenario(new URL("http://127.0.0.1:43210"), + new AbortController().signal, mockFetch))).toBe("final_result_mismatch"); + }); +}); diff --git a/tests/providers/codebuddy-mcp-server.test.ts b/tests/providers/codebuddy-mcp-server.test.ts new file mode 100644 index 00000000000..3ac60b6cd71 --- /dev/null +++ b/tests/providers/codebuddy-mcp-server.test.ts @@ -0,0 +1,188 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CODEBUDDY_TOOL_LIMITS } from "../../src/adapters/codebuddy/tool-bridge"; + +const tempDirs: string[] = []; +const serverPath = join( + import.meta.dir, + "..", + "..", + "src", + "adapters", + "codebuddy", + "mcp-server.ts", +); + +function definition( + name: string, + overrides: Record = {}, +): Record { + return { + name, + description: `Description for ${name}`, + inputSchema: { type: "object" }, + ...overrides, + }; +} + +async function rejectedCatalog(rawCatalog: string): Promise { + const dir = mkdtempSync(join(tmpdir(), "opencodex-codebuddy-mcp-reject-")); + tempDirs.push(dir); + const catalogPath = join(dir, "tools.json"); + writeFileSync(catalogPath, rawCatalog, { mode: 0o600 }); + const child = Bun.spawn({ + cmd: [process.execPath, serverPath, catalogPath], + stdout: "ignore", + stderr: "pipe", + }); + const stderrPromise = new Response(child.stderr).text(); + const exitCode = await child.exited; + const stderr = await stderrPromise; + expect(exitCode).not.toBe(0); + return stderr; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("CodeBuddy capture-only MCP server", () => { + test("advertises only the private catalog, rejects unknown tools, and never executes known tools", async () => { + const dir = mkdtempSync(join(tmpdir(), "opencodex-codebuddy-mcp-test-")); + tempDirs.push(dir); + const catalogPath = join(dir, "tools.json"); + writeFileSync(catalogPath, JSON.stringify([{ + name: "lookup", + description: "Look up an item.", + inputSchema: { + type: "object", + properties: { id: { type: "number" } }, + required: ["id"], + }, + }]), { mode: 0o600 }); + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverPath, catalogPath], + stderr: "pipe", + }); + const client = new Client({ name: "codebuddy-capture-test", version: "1.0.0" }); + + try { + await client.connect(transport); + const listed = await client.listTools(); + expect(listed.tools).toEqual([{ + name: "lookup", + description: "Look up an item.", + inputSchema: { + type: "object", + properties: { id: { type: "number" } }, + required: ["id"], + }, + }]); + + // Settle the real stdio exchange before asserting: Bun 1.4.0's rejection + // matcher stalls this SDK response when it waits on the pending call itself. + const unknownError = await client.callTool({ + name: "not-advertised", + arguments: {}, + }).catch((error: unknown) => error); + expect(unknownError).toBeInstanceOf(Error); + expect((unknownError as Error).message).toContain("unknown isolated tool"); + + const abort = new AbortController(); + let settled = false; + const pending = client.callTool({ + name: "lookup", + arguments: { id: 7 }, + }, undefined, { signal: abort.signal }); + void pending.finally(() => { settled = true; }).catch(() => {}); + await Bun.sleep(50); + expect(settled).toBe(false); + abort.abort(); + await expect(pending).rejects.toThrow(); + } finally { + await client.close(); + } + }); + + test("reads at most the catalog limit plus one byte", async () => { + const stderr = await rejectedCatalog( + " ".repeat(CODEBUDDY_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++) { + deeplyNested = { type: "object", nested: deeplyNested }; + } + + const cases: Array<{ expected: string; value: unknown }> = [ + { + expected: "too many definitions", + value: Array.from( + { length: CODEBUDDY_TOOL_LIMITS.maxTools + 1 }, + (_, index) => definition(`tool_${index}`), + ), + }, + { + expected: "duplicate names", + value: [definition("same"), definition("same")], + }, + { + expected: "invalid definition", + value: [definition("invalid name")], + }, + { + expected: "invalid definition", + value: [definition("description", { + description: "d".repeat(CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + 1), + })], + }, + { + expected: "object type", + value: [definition("wrong_root", { inputSchema: { type: "array" } })], + }, + { + expected: "too deeply nested", + value: [definition("deep", { inputSchema: deeplyNested })], + }, + ]; + + for (const { expected, value } of cases) { + const stderr = await rejectedCatalog(JSON.stringify(value)); + expect(stderr).toContain(expected); + } + }); + + test("exits when stdin closes instead of outliving the CLI", async () => { + // The pinned MCP SDK (1.30.0) does not detect stdin EOF itself. Without the explicit + // end/close handlers, this capture server would linger as an orphaned bun process + // whenever the parent terminates the CLI it serves. + const dir = mkdtempSync(join(tmpdir(), "opencodex-codebuddy-mcp-eof-")); + tempDirs.push(dir); + const catalogPath = join(dir, "tools.json"); + writeFileSync(catalogPath, JSON.stringify([definition("lookup")]), { mode: 0o600 }); + const child = Bun.spawn({ + cmd: [process.execPath, serverPath, catalogPath], + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }); + child.stdin.end(); + const exit = await Promise.race([ + child.exited, + Bun.sleep(4_000).then(() => "timeout" as const), + ]); + if (exit === "timeout") child.kill(); + expect(exit).toBe(0); + }); +}); diff --git a/tests/providers/codebuddy-protocol.test.ts b/tests/providers/codebuddy-protocol.test.ts index 5c3b4c8db29..3cf7690156b 100644 --- a/tests/providers/codebuddy-protocol.test.ts +++ b/tests/providers/codebuddy-protocol.test.ts @@ -251,6 +251,85 @@ describe("codebuddy stream-json event mapping", () => { test("usageFromResult returns undefined when no usage is present", () => { expect(usageFromResult({ type: "result" })).toBeUndefined(); }); + + test("a cache-creation-only usage snapshot is kept instead of collapsing to undefined", () => { + // A capture-only tool leg ends at message_stop with no result frame, so a snapshot whose only + // non-zero counter is cache creation is the sole token accounting the turn will ever see. + expect(usageFromResult({ + type: "result", + usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 200 }, + })).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cacheCreationInputTokens: 200, + }); + // Cache-creation-only through the partial fold too: message_start carries it before any delta. + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + mapStreamMessageToEvents( + { type: "stream_event", event: { type: "message_start", message: { usage: { cache_creation_input_tokens: 7 } } } }, + state, + ); + expect(state.partialUsage).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cacheCreationInputTokens: 7, + }); + }); + + test("a zero-valued cache-read counter stays absent instead of reporting a phantom cache hit", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + mapStreamMessageToEvents( + { type: "stream_event", event: { type: "message_delta", usage: { input_tokens: 9, output_tokens: 1, cache_read_input_tokens: 0 } } }, + state, + ); + expect(state.partialUsage).toEqual({ inputTokens: 9, outputTokens: 1, totalTokens: 10 }); + expect(state.partialUsage).not.toHaveProperty("cachedInputTokens"); + }); + + test("message_delta and assistant usage snapshots fold into partialUsage; result stays authoritative", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + // Zero-only snapshots are ignored so a tool-bridge turn without vendor usage stays absent. + mapStreamMessageToEvents( + { type: "stream_event", event: { type: "message_delta", usage: { input_tokens: 0, output_tokens: 0 } } }, + state, + ); + expect(state.partialUsage).toBeUndefined(); + // First real snapshot sticks. + mapStreamMessageToEvents( + { type: "stream_event", event: { type: "message_delta", usage: { input_tokens: 12, output_tokens: 5 } } }, + state, + ); + expect(state.partialUsage).toEqual({ inputTokens: 12, outputTokens: 5, totalTokens: 17 }); + // A later snapshot maxes each field instead of trusting frame order. + mapStreamMessageToEvents( + { type: "stream_event", event: { type: "message_delta", usage: { input_tokens: 15, output_tokens: 4, cache_read_input_tokens: 3 } } }, + state, + ); + expect(state.partialUsage).toEqual({ + inputTokens: 15, outputTokens: 5, totalTokens: 20, cachedInputTokens: 3, cacheReadInputTokens: 3, + }); + // Assistant-frame usage snapshots participate in the same fold. + mapStreamMessageToEvents( + { type: "assistant", message: { role: "assistant", content: [], usage: { input_tokens: 10, output_tokens: 9 } } }, + state, + ); + expect(state.partialUsage).toMatchObject({ inputTokens: 15, outputTokens: 9, totalTokens: 24 }); + // message_start carries input tokens in Anthropic-shaped streams; a capture-only tool leg + // terminates at message_stop before any result frame, so this snapshot must be recorded. + mapStreamMessageToEvents( + { type: "stream_event", event: { type: "message_start", message: { usage: { input_tokens: 40, output_tokens: 0 } } } }, + state, + ); + expect(state.partialUsage).toMatchObject({ inputTokens: 40, outputTokens: 9, totalTokens: 49 }); + // A terminal result frame carries its own usage and does not consult partialUsage. + const events = mapStreamMessageToEvents( + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 30, output_tokens: 2 } }, + state, + ); + expect(events).toEqual([{ type: "done", stopReason: "stop", usage: { inputTokens: 30, outputTokens: 2, totalTokens: 32 } }]); + }); }); describe("codebuddy conversation input builder (Strategy C projection)", () => { diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts new file mode 100644 index 00000000000..3c9ebbcacf1 --- /dev/null +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -0,0 +1,484 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { existsSync, readdirSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +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 { CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../../src/adapters/codebuddy/profiles"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const enc = new TextEncoder(); + +beforeEach(() => clearCodeBuddyBinaryCache()); + +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: { a: { type: "number" } } }, + }; +} + +function parsed(tools: OcxTool[] = []): OcxParsedRequest { + return { + modelId: "kimi-k3-1", + stream: true, + options: {}, + context: { + messages: [{ role: "user", content: "Use a tool", timestamp: 1 }], + ...(tools.length > 0 ? { tools } : {}), + }, + } as OcxParsedRequest; +} + +function provider(): OcxProviderConfig { + return { + adapter: "codebuddy", + baseUrl: CODEBUDDY_GLOBAL_PROFILE.canonicalBaseUrl, + apiKey: "cb-global-key", + reasoningEfforts: ["low", "high", "xhigh", "max"], + } as OcxProviderConfig; +} + +function incoming() { + return { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }; +} + +async function run(adapter: ReturnType, p: OcxParsedRequest): Promise { + const events: AdapterEvent[] = []; + await adapter.runTurn!(p, incoming(), e => events.push(e)); + return events; +} + +function frameLines(frames: unknown[]): Uint8Array[] { + return frames.map(f => enc.encode(JSON.stringify(f) + "\n")); +} + +const INIT_OK = { type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }; +const INIT_EMPTY = { type: "system", subtype: "init", mcp_servers: [] }; + +function toolUseStart(name: string, id = "tu_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("CodeBuddy capture-only tool bridge turn", () => { + test.each(["catalog.json", "mcp.json"])("bridge staging failure in %s is private and cleans both staging directories", async failedFile => { + const before = new Set(readdirSync(tmpdir())); + const promptDirs: string[] = []; + const writes: string[] = []; + let bridgeDir: string | undefined; + let spawns = 0; + const adapter = createCodeBuddyAdapter(provider(), { + which: () => "/usr/bin/codebuddy", + spawn: () => { spawns++; throw new Error("must not spawn after staging failure"); }, + writeToolBridgeFile: async (path, data, options) => { + const target = String(path); + bridgeDir = dirname(target); + writes.push(basename(target)); + if (basename(target) === failedFile) { + promptDirs.push(...readdirSync(tmpdir()) + .filter(name => name.startsWith("ocx-codebuddy-prompt-") && !before.has(name)) + .map(name => join(tmpdir(), name))); + throw new Error(`ENOSPC private-path=${target} token=fixture-sensitive-value`); + } + await writeFile(path, data, options); + }, + }); + const events = await run(adapter, parsed([tool("exec")])); + expect(writes).toEqual(failedFile === "catalog.json" ? ["catalog.json"] : ["catalog.json", "mcp.json"]); + expect(events).toEqual([{ + type: "error", + message: "Coding-agent tool bridge could not be staged securely.", + status: 500, + errorType: "server_error", + code: "tool_bridge_setup_failed", + retryable: false, + }]); + expect(spawns).toBe(0); + expect(bridgeDir).toBeDefined(); + expect(existsSync(bridgeDir!)).toBe(false); + expect(promptDirs).toHaveLength(1); + expect(existsSync(promptDirs[0]!)).toBe(false); + }); + + 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 cliName = [...bridge.emittedNameMap.keys()][0]!; + const wireName = bridge.emittedNameMap.get(cliName)!; + + let child: FakeChild | undefined; + let seenArgs: readonly string[] = []; + const spawn: SpawnFn = (_cmd, args) => { + seenArgs = args; + child = fakeChild(frameLines([ + INIT_OK, + toolUseStart(cliName), + inputJsonDelta('{"a":'), + inputJsonDelta("1}"), + BLOCK_STOP, + MESSAGE_STOP, + // Deliberately no result frame: in production the CLI parks on the + // never-answering capture server after message_stop. + ])); + return child as unknown as ChildProcess; + }; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + + expect(seenArgs).toContain("--strict-mcp-config"); + expect(seenArgs[seenArgs.indexOf("--tools") + 1]).toBe(""); + const allowedIdx = seenArgs.indexOf("--allowedTools"); + expect(allowedIdx).toBeGreaterThanOrEqual(0); + expect(seenArgs[allowedIdx + 1]).toBe(cliName); + const mcpIdx = seenArgs.indexOf("--mcp-config"); + expect(mcpIdx).toBeGreaterThanOrEqual(0); + expect(seenArgs[mcpIdx + 1]).toContain("ocx-coding-agent-tools-"); + // The private temp dir is removed once the turn settles. + expect(existsSync(dirname(seenArgs[mcpIdx + 1]!))).toBe(false); + + expect(events.map(e => e.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); + }); + + test("a request without tools 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 = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, parsed()); + expect(seenArgs).not.toContain("--mcp-config"); + expect(seenArgs).not.toContain("--allowedTools"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("a tool-bridge turn reports the partial usage observed before message_stop", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(p); + const cliName = [...bridge.emittedNameMap.keys()][0]!; + const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ + INIT_OK, + { type: "stream_event", event: { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { input_tokens: 12, output_tokens: 5 } } }, + toolUseStart(cliName), + inputJsonDelta("{}"), + BLOCK_STOP, + { type: "stream_event", event: { type: "message_delta", delta: {}, usage: { input_tokens: 15, output_tokens: 4, cache_read_input_tokens: 3 } } }, + MESSAGE_STOP, + // No result frame: the CLI parks on the never-answering capture server after message_stop. + ])) as unknown as ChildProcess; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ + type: "done", + stopReason: "tool_use", + endTurn: false, + usage: { inputTokens: 15, outputTokens: 5, totalTokens: 20, cachedInputTokens: 3, cacheReadInputTokens: 3 }, + }); + }); + + test("a tool-bridge turn records input tokens from message_start", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(p); + const cliName = [...bridge.emittedNameMap.keys()][0]!; + const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ + INIT_OK, + { type: "stream_event", event: { type: "message_start", message: { usage: { input_tokens: 31, output_tokens: 0 } } } }, + toolUseStart(cliName), + inputJsonDelta("{}"), + BLOCK_STOP, + { type: "stream_event", event: { type: "message_delta", delta: {}, usage: { input_tokens: 31, output_tokens: 6 } } }, + MESSAGE_STOP, + ])) as unknown as ChildProcess; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ + type: "done", + stopReason: "tool_use", + usage: { inputTokens: 31, outputTokens: 6, totalTokens: 37 }, + }); + }); + + test("message_stop with an incomplete tool call fails with protocol_error", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(p); + const cliName = [...bridge.emittedNameMap.keys()][0]!; + const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ + INIT_OK, + toolUseStart(cliName), + inputJsonDelta("{}"), + // Missing BLOCK_STOP (tool_call_end not emitted, so toolCallStarts=1, completedToolCalls=0) + MESSAGE_STOP, + ])) as unknown as ChildProcess; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "protocol_error", + status: 502, + retryable: false, + }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("a terminal result with an incomplete tool call fails with protocol_error", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(p); + const cliName = [...bridge.emittedNameMap.keys()][0]!; + const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ + INIT_OK, + toolUseStart(cliName), + inputJsonDelta("{}"), + // Missing BLOCK_STOP (toolCallStarts=1, completedToolCalls=0) and no message_stop: the + // stream ends via a terminal result frame, which previously emitted done and let the + // open call slip through as a successful turn. + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 7, output_tokens: 2 } }, + ])) as unknown as ChildProcess; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "protocol_error", + status: 502, + retryable: false, + }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + 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 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 + // when the model started calling tools. + toolUseStart(cliName), + inputJsonDelta("{}"), + BLOCK_STOP, + INIT_OK, + MESSAGE_STOP, + ])) as unknown as ChildProcess; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "tool_bridge_init_missing", + status: 502, + retryable: false, + }); + // No tool lifecycle events surface from an unvalidated bridge. + expect(events.some(e => e.type === "tool_call_start")).toBe(false); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + 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 cliName = [...bridge.emittedNameMap.keys()][0]!; + let child: FakeChild | undefined; + const spawn: SpawnFn = (_cmd, _args) => { + child = fakeChild(frameLines([ + INIT_OK, + toolUseStart(cliName), + inputJsonDelta("{}"), + BLOCK_STOP, + // The CLI settles with a successful result while every captured call is already + // complete, instead of parking on the never-answering capture server. + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 42, output_tokens: 8 } }, + MESSAGE_STOP, + ])); + return child as unknown as ChildProcess; + }; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + + // The result-derived done(stop) must never surface: the leg ends as done(tool_use) with + // the vendor result frame's usage folded in. + expect(events.map(e => e.type)).toEqual([ + "tool_call_start", + "tool_call_delta", + "tool_call_end", + "done", + ]); + expect(events.at(-1)).toMatchObject({ + type: "done", + stopReason: "tool_use", + endTurn: false, + usage: { inputTokens: 42, outputTokens: 8, totalTokens: 50 }, + }); + expect(events.some(e => e.type === "done" && e.stopReason === "stop")).toBe(false); + expect(child?.killed).toBe(true); + }); + + test("a deferred result without message_stop fails closed with protocol_error", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(p); + const cliName = [...bridge.emittedNameMap.keys()][0]!; + const spawn: SpawnFn = (_cmd, _args) => fakeChild(frameLines([ + INIT_OK, + toolUseStart(cliName), + inputJsonDelta("{}"), + BLOCK_STOP, + // Result arrives but message_stop never does: the stream ends before the synthesized + // terminal event can be emitted. + { type: "result", subtype: "success", is_error: false }, + ])) as unknown as ChildProcess; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "protocol_error", + status: 502, + retryable: false, + }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("tool_choice required without a captured call fails closed instead of a text done", async () => { + const p = parsed([tool("exec")]); + p.options = { toolChoice: "required" } as OcxParsedRequest["options"]; + let child: FakeChild | undefined; + const spawn: SpawnFn = (_cmd, _args) => { + child = fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]); + return child as unknown as ChildProcess; + }; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "tool_call_required", + status: 502, + retryable: false, + }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("tool_choice auto keeps a text-only result as a normal done", async () => { + const p = parsed([tool("exec")]); + const spawn: SpawnFn = () => fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "stop" }); + }); + + test("a synchronous spawn throw still removes the private temp dir", async () => { + const p = parsed([tool("exec")]); + // Diff-based so a concurrently running proxy's own bridge dirs can never flake this. + const before = new Set(readdirSync(tmpdir()).filter(name => name.startsWith("ocx-coding-agent-tools-"))); + const spawn: SpawnFn = () => { throw new Error("spawn exploded"); }; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, p); + expect(events[0]).toMatchObject({ type: "error", code: "cli_spawn_failed" }); + const leftovers = readdirSync(tmpdir()) + .filter(name => name.startsWith("ocx-coding-agent-tools-") && !before.has(name)); + expect(leftovers).toEqual([]); + }); + + test("an init frame without the bridge server fails closed", async () => { + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(frameLines([INIT_EMPTY])) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + }); + const events = await run(adapter, parsed([tool("exec")])); + expect(events[0]).toMatchObject({ type: "error", code: "tool_bridge_init_mismatch", retryable: false }); + }); + + test("a tool call that precedes the init handshake fails closed", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(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. + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(frameLines([ + toolUseStart(cliName), + inputJsonDelta("{}"), + BLOCK_STOP, + INIT_OK, + MESSAGE_STOP, + ])) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "tool_bridge_init_missing", + status: 502, + retryable: false, + }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("a tool call outside the advertised catalog fails closed", async () => { + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(frameLines([ + INIT_OK, + toolUseStart("mcp__opencodex__evil"), + inputJsonDelta("{}"), + BLOCK_STOP, + MESSAGE_STOP, + ])) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + }); + const events = await run(adapter, parsed([tool("exec")])); + expect(events[0]).toMatchObject({ type: "error", code: "undeclared_tool_call", retryable: false }); + }); + + test("more captured calls than the turn limit fails closed", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(p); + const cliName = [...bridge.emittedNameMap.keys()][0]!; + const frames: unknown[] = [INIT_OK]; + for (let i = 0; i < 17; i += 1) { + frames.push(toolUseStart(cliName, `tu_${i}`)); + frames.push(BLOCK_STOP); + } + frames.push(MESSAGE_STOP); + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(frameLines(frames)) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + }); + const events = await run(adapter, p); + expect(events.at(-1)).toMatchObject({ type: "error", code: "tool_call_limit" }); + }); +}); diff --git a/tests/providers/codebuddy-tool-bridge.test.ts b/tests/providers/codebuddy-tool-bridge.test.ts new file mode 100644 index 00000000000..ccdeccba405 --- /dev/null +++ b/tests/providers/codebuddy-tool-bridge.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, test } from "bun:test"; +import { + CODEBUDDY_MCP_TOOL_PREFIX, + CODEBUDDY_TOOL_LIMITS, + buildCodeBuddyToolBridge, + codeBuddyToolAlias, +} from "../../src/adapters/codebuddy/tool-bridge"; +import type { OcxParsedRequest, OcxTool, OcxToolChoice } from "../../src/types"; + +function tool( + name: string, + options: Partial = {}, +): OcxTool { + return { + name, + description: `Description for ${name}`, + parameters: { type: "object", properties: {} }, + ...options, + }; +} + +function parsed(tools: OcxTool[], toolChoice?: OcxToolChoice): OcxParsedRequest { + return { + modelId: "kimi-k3-2", + context: { + messages: [{ role: "user", content: "Use a tool", timestamp: 1 }], + tools, + }, + stream: true, + options: { toolChoice }, + }; +} + +function wireNames(request: OcxParsedRequest): string[] { + return [...buildCodeBuddyToolBridge(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)]), + ); +} + +describe("CodeBuddy capture-only tool choice", () => { + const catalog = [ + tool("plain"), + tool("lookup", { namespace: "mcp__alpha" }), + ]; + + test("supports auto, none, and required", () => { + const automatic = buildCodeBuddyToolBridge(parsed(catalog, "auto")); + expect([...automatic.emittedNameMap.values()]).toEqual(["plain", "mcp__alpha__lookup"]); + expect(automatic.requireToolCall).toBe(false); + + const none = buildCodeBuddyToolBridge(parsed(catalog, "none")); + expect(none.tools).toEqual([]); + expect(none.emittedNameMap.size).toBe(0); + expect(none.requireToolCall).toBe(false); + + const required = buildCodeBuddyToolBridge(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 }, + (_, index) => tool(`ignored_${index}`, { + description: index === 0 + ? "d".repeat(CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + 1) + : `Ignored ${index}`, + parameters: index === 1 + ? { type: "array" } + : { type: "object", properties: {} }, + }), + ); + + const bridge = buildCodeBuddyToolBridge(parsed(ignored, "none")); + expect(bridge.tools).toEqual([]); + expect(bridge.emittedNameMap.size).toBe(0); + expect(bridge.requireToolCall).toBe(false); + }); + + test("validates only definitions selected by named and allowed-tools choices", () => { + const selected = tool("selected"); + const ignored = [ + null as unknown as OcxTool, + tool("invalid name"), + tool("bad_description", { + description: "d".repeat(CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + 1), + }), + tool("bad_schema", { parameters: { type: "array" } }), + ...Array.from( + { length: CODEBUDDY_TOOL_LIMITS.maxTools }, + (_, index) => tool(`extra_${index}`), + ), + ]; + + const named = buildCodeBuddyToolBridge(parsed([selected, ...ignored], { name: "selected" })); + expect([...named.emittedNameMap.values()]).toEqual(["selected"]); + expect(named.requireToolCall).toBe(true); + + const allowed = buildCodeBuddyToolBridge(parsed([selected, ...ignored], { + allowedTools: ["selected"], + mode: "auto", + })); + expect([...allowed.emittedNameMap.values()]).toEqual(["selected"]); + expect(allowed.requireToolCall).toBe(false); + + expect(() => buildCodeBuddyToolBridge(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 })); + expect([...bridge.emittedNameMap.values()]).toEqual(["mcp__alpha__lookup"]); + expect(bridge.requireToolCall).toBe(true); + } + + expect(() => buildCodeBuddyToolBridge(parsed(catalog, { name: "missing" }))) + .toThrow("tool_choice requires a tool"); + }); + + test("supports allowed_tools in auto and required modes", () => { + const automatic = buildCodeBuddyToolBridge(parsed(catalog, { + allowedTools: ["plain"], + mode: "auto", + })); + expect([...automatic.emittedNameMap.values()]).toEqual(["plain"]); + expect(automatic.requireToolCall).toBe(false); + + const required = buildCodeBuddyToolBridge(parsed(catalog, { + allowedTools: ["lookup"], + mode: "required", + })); + expect([...required.emittedNameMap.values()]).toEqual(["mcp__alpha__lookup"]); + expect(required.requireToolCall).toBe(true); + + const noMatch = buildCodeBuddyToolBridge(parsed(catalog, { + allowedTools: ["missing"], + mode: "auto", + })); + expect(noMatch.tools).toEqual([]); + expect(() => buildCodeBuddyToolBridge(parsed(catalog, { + allowedTools: ["missing"], + mode: "required", + }))).toThrow("tool_choice requires a tool"); + }); + + test("fails closed for ambiguous bare selectors", () => { + const ambiguous = [ + tool("lookup", { namespace: "mcp__alpha" }), + tool("lookup", { namespace: "mcp__beta" }), + ]; + expect(wireNames(parsed(ambiguous, { + allowedTools: ["lookup"], + mode: "auto", + }))).toEqual([]); + expect(() => buildCodeBuddyToolBridge(parsed(ambiguous, { name: "lookup" }))) + .toThrow("tool_choice requires a tool"); + expect(wireNames(parsed(ambiguous, { name: "mcp__beta.lookup" }))) + .toEqual(["mcp__beta__lookup"]); + }); +}); + +describe("CodeBuddy tool aliases", () => { + test("are deterministic, collision-safe, and reversibly mapped", () => { + const unsafeWireName = "unsafe.name"; + const firstHashedCandidate = codeBuddyToolAlias(unsafeWireName); + const catalog = [ + tool(unsafeWireName), + tool(firstHashedCandidate), + tool("unsafe/name"), + tool("x".repeat(80)), + ]; + + const forward = wireToAlias(parsed(catalog)); + const reverseOrder = wireToAlias(parsed([...catalog].reverse())); + expect([...forward].sort()).toEqual([...reverseOrder].sort()); + expect(forward.get(firstHashedCandidate)).toBe(firstHashedCandidate); + expect(forward.get(unsafeWireName)).not.toBe(firstHashedCandidate); + expect(new Set(forward.values()).size).toBe(catalog.length); + + 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); + } + }); + + test("rejects duplicate source wire names instead of inventing an ambiguous mapping", () => { + expect(() => buildCodeBuddyToolBridge(parsed([tool("same"), tool("same")]))) + .toThrow("duplicate wire name"); + }); +}); + +describe("CodeBuddy JSON Schema boundary", () => { + test("preserves a normal complex schema and strips only Responses-private encrypted markers", () => { + const parameters = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + $defs: { + address: { + type: "object", + properties: { + city: { type: "string", minLength: 1 }, + postcode: { type: "string", pattern: "^[0-9]{5}$" }, + }, + required: ["city"], + additionalProperties: false, + }, + }, + properties: { + address: { $ref: "#/$defs/address" }, + mode: { oneOf: [{ const: "fast" }, { const: "safe" }] }, + tags: { + type: "array", + prefixItems: [{ type: "string" }], + items: { type: "string", pattern: "^[a-z]+$" }, + minItems: 1, + maxItems: 5, + uniqueItems: true, + }, + metadata: { + type: "object", + patternProperties: { + "^x-": { type: ["string", "number", "boolean", "null"] }, + }, + additionalProperties: false, + }, + encrypted: { type: "string", encrypted: true }, + payload: { type: "object", default: { encrypted: true } }, + }, + required: ["address", "mode"], + dependentRequired: { address: ["mode"] }, + if: { properties: { mode: { const: "fast" } } }, + then: { properties: { tags: { minItems: 2 } } }, + else: { properties: { tags: { maxItems: 2 } } }, + additionalProperties: false, + encrypted: true, + }; + const bridge = buildCodeBuddyToolBridge(parsed([tool("complex", { parameters })])); + const schema = bridge.tools[0].inputSchema as typeof parameters; + + expect(schema.$defs).toEqual(parameters.$defs); + expect(schema.properties.address).toEqual({ $ref: "#/$defs/address" }); + expect(schema.properties.mode).toEqual(parameters.properties.mode); + expect(schema.properties.tags).toEqual(parameters.properties.tags); + expect(schema.properties.metadata).toEqual(parameters.properties.metadata); + expect(schema.properties.encrypted).toEqual({ type: "string" }); + expect(schema.properties.payload.default).toEqual({ encrypted: true }); + expect(Object.hasOwn(schema, "encrypted")).toBe(false); + expect(parameters.encrypted).toBe(true); + expect(parameters.properties.encrypted.encrypted).toBe(true); + }); + + test("adds the MCP-required root object type without mutating the source", () => { + const parameters = { + properties: { value: { type: "integer", minimum: 0 } }, + required: ["value"], + }; + const schema = buildCodeBuddyToolBridge(parsed([tool("normalize", { parameters })])) + .tools[0].inputSchema; + expect(schema).toEqual({ ...parameters, type: "object" }); + expect(Object.hasOwn(parameters, "type")).toBe(false); + }); + + test.each([ + ["non-object root", { type: "array", items: { type: "string" } }], + ["unknown type", { type: "object", properties: { value: { type: "mystery" } } }], + ["malformed properties", { type: "object", properties: [] }], + ["empty composition", { type: "object", allOf: [] }], + ["invalid regex", { type: "object", patternProperties: { "[": { type: "string" } } }], + ["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([ + tool("invalid", { parameters: parameters as Record }), + ]))).toThrow("invalid input schema"); + }); + + test("rejects cyclic and accessor-bearing schemas before serialization", () => { + const cyclic: Record = { type: "object" }; + cyclic.self = cyclic; + expect(() => buildCodeBuddyToolBridge(parsed([tool("cyclic", { parameters: cyclic })]))) + .toThrow(/invalid input schema.*cycles/); + + const accessor: Record = { type: "object" }; + Object.defineProperty(accessor, "properties", { + enumerable: true, + get: () => ({ value: { type: "string" } }), + }); + expect(() => buildCodeBuddyToolBridge(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([ + tool("prototype_names", { parameters: { type: "object", properties } }), + ])).tools[0].inputSchema; + const emitted = schema.properties as Record; + expect(Object.hasOwn(emitted, "__proto__")).toBe(true); + expect(emitted.__proto__).toEqual({ type: "string" }); + expect(emitted.constructor).toEqual({ type: "number" }); + }); +}); + +describe("CodeBuddy tool catalog limits", () => { + test("bounds tool count, name bytes, and description bytes", () => { + const tooMany = Array.from( + { length: CODEBUDDY_TOOL_LIMITS.maxTools + 1 }, + (_, index) => tool(`tool_${index}`), + ); + expect(() => buildCodeBuddyToolBridge(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 oversizedDescription = "d".repeat(CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes + 1); + expect(() => buildCodeBuddyToolBridge(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++) { + tooDeep = { nested: tooDeep }; + } + expect(() => buildCodeBuddyToolBridge(parsed([ + tool("deep", { parameters: { type: "object", extension: tooDeep } }), + ]))).toThrow(/invalid input schema.*too deep/); + + const tooManyNodes = Array.from( + { length: CODEBUDDY_TOOL_LIMITS.maxSchemaNodes }, + (_, index) => `value_${index}`, + ); + expect(() => buildCodeBuddyToolBridge(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([ + tool("large_schema", { + parameters: { + type: "object", + $comment: "s".repeat(CODEBUDDY_TOOL_LIMITS.maxSchemaBytes), + }, + }), + ]))).toThrow(/invalid input schema.*schema exceeds/); + + expect(() => buildCodeBuddyToolBridge(parsed([ + tool("large_definition", { + description: "d".repeat(60 * 1024), + parameters: { type: "object", $comment: "s".repeat(200 * 1024) }, + }), + ]))).toThrow("definition exceeds"); + + const aggregate = Array.from( + { length: 40 }, + (_, index) => tool(`aggregate_${index}`, { description: "d".repeat(55 * 1024) }), + ); + expect(() => buildCodeBuddyToolBridge(parsed(aggregate))).toThrow("catalog exceeds"); + }); +}); diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 4b6605c3169..1859670288c 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -592,6 +592,38 @@ describe("Responses parser", () => { { type: "image", imageUrl: "data:image/png;base64,aGVsbG8=", detail: "high" }, ]); }); + + test("repairs a history function_call that lost its JSON opening brace", () => { + const parsed = parseRequest({ + model: "codebuddy-cn/glm-5.3", + input: [ + { type: "function_call", call_id: "call_6c903fcfec9947a8b7aff270", name: "js", arguments: 'code":"let log = [];","timeout_ms":90000}' }, + { type: "function_call_output", call_id: "call_6c903fcfec9947a8b7aff270", output: "" }, + ], + }); + const assistant = parsed.context.messages.find(m => m.role === "assistant"); + const toolCall = assistant?.content.find(part => part.type === "toolCall") as + | { arguments: Record } + | undefined; + + expect(toolCall?.arguments).toEqual({ code: "let log = [];", timeout_ms: 90000 }); + }); + + test("keeps the tolerated-{} fallback for arguments that are not a repairable envelope", () => { + const parsed = parseRequest({ + model: "codebuddy-cn/glm-5.3", + input: [ + { type: "function_call", call_id: "call_freeform", name: "js", arguments: "not json at all" }, + { type: "function_call_output", call_id: "call_freeform", output: "" }, + ], + }); + const assistant = parsed.context.messages.find(m => m.role === "assistant"); + const toolCall = assistant?.content.find(part => part.type === "toolCall") as + | { arguments: Record } + | undefined; + + expect(toolCall?.arguments).toEqual({}); + }); }); describe("codex-rs compat surface (260707)", () => {