From 6691dce749a3b065bdb6caf5030b26911bc73a01 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sat, 19 Sep 2026 17:07:00 +0800 Subject: [PATCH 01/11] feat(codebuddy): advertise the Codex tool catalog through a capture-only MCP bridge Port the tool-bridge design from the pre-coding-agent implementation onto the shared runTurn framework. When a request declares tools, the adapter builds a validated catalog (bounded count, names, descriptions, schemas), the turn writes it plus an MCP config into a private temp dir, and the CLI is launched with --mcp-config and exact --allowedTools alongside the existing --tools ""/--strict-mcp-config posture. The capture-only server advertises schemas over ListTools and never answers CallTool; the turn ends at message_stop, terminates the process tree, and emits the captured tool_use blocks as tool_call events with request wire names. The client keeps approval, sandboxing, and execution; tool results continue the conversation through the existing stream-json history projection. Fail-closed boundaries: tool-bridge init validation (the CLI must report the capture server connected), undeclared tool names, a 16-call turn limit, and bridge setup failures. Requests without tools keep the exact v1 text-only arg shape. --- src/adapters/codebuddy/adapter.ts | 73 ++- src/adapters/codebuddy/mcp-server.ts | 177 ++++++ src/adapters/codebuddy/tool-bridge.ts | 597 ++++++++++++++++++ src/adapters/coding-agent/protocol.ts | 35 + src/adapters/coding-agent/turn.ts | 164 ++++- tests/providers/codebuddy-mcp-server.test.ts | 161 +++++ .../codebuddy-tool-bridge-turn.test.ts | 196 ++++++ tests/providers/codebuddy-tool-bridge.test.ts | 370 +++++++++++ 8 files changed, 1768 insertions(+), 5 deletions(-) create mode 100644 src/adapters/codebuddy/mcp-server.ts create mode 100644 src/adapters/codebuddy/tool-bridge.ts create mode 100644 tests/providers/codebuddy-mcp-server.test.ts create mode 100644 tests/providers/codebuddy-tool-bridge-turn.test.ts create mode 100644 tests/providers/codebuddy-tool-bridge.test.ts diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index a769ac37dae..c0ce682713b 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -1,14 +1,42 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +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,7 +63,12 @@ export function buildChildEnv(profile: CodeBuddyProfile, apiKey: string): Record * 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). */ -export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] { +export function buildArgs( + profile: CodeBuddyProfile, + parsed: OcxParsedRequest, + provider: OcxProviderConfig, + toolBridge?: Pick, +): string[] { const args: string[] = [ "-p", "--output-format", "stream-json", @@ -50,8 +83,11 @@ export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, p ]; const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); if (effort) args.push("--effort", effort); + const systemParts: string[] = []; const system = buildSystemPrompt(parsed); - if (system) args.push("--append-system-prompt", system); + if (system) systemParts.push(system); + if (toolBridge && toolBridge.tools.length > 0) systemParts.push(TOOL_BRIDGE_SYSTEM_PROMPT); + if (systemParts.length > 0) args.push("--append-system-prompt", systemParts.join("\n\n")); // profile is retained for symmetry with the region-isolated design and future per-region flags. void profile; return args; @@ -71,13 +107,42 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu }, async runTurn(parsed, incoming, emit): Promise { + 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, + } + : undefined; await runCodingAgentTurn({ profiles: CODEBUDDY_PROFILES, provider, parsed, incoming, emit: guardCodeBuddyScaffolding(emit), - buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov), + ...(bridgeInput ? { toolBridge: bridgeInput } : {}), + buildArgs: (resolved, req, prov) => buildArgs( + resolved as CodeBuddyProfile, + req, + prov, + toolBridge.tools.length > 0 ? toolBridge : undefined, + ), 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..f92b7a91316 --- /dev/null +++ b/src/adapters/codebuddy/mcp-server.ts @@ -0,0 +1,177 @@ +/** + * 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"); +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/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..ca9d9d7b118 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -166,6 +166,10 @@ 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; } /** @@ -305,14 +309,45 @@ 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; + } + 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..5c283d234a7 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. */ @@ -88,9 +99,31 @@ 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; +} + /** * Run one headless coding-agent CLI turn as an OpenCodex `runTurn` (§七/§三十). * @@ -153,7 +186,61 @@ 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 }); @@ -278,13 +365,85 @@ 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; + } emitOnce(event.type === "error" ? { ...event, message: redactSecrets(event.message, profile.tokenEnv, apiKey) } : event); } + if (failClosed) break; + if (toolBridge && !terminalEmitted && state.sawMessageStop && (state.completedToolCalls ?? 0) > 0) { + if (!initValidated) { + emitOnce({ + type: "error", + message: "Coding-agent tool bridge init frame was not observed before the first tool call.", + status: 502, + errorType: "upstream_error", + code: "tool_bridge_init_missing", + retryable: false, + }); + kill(); + break; + } + // 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. + emitOnce({ type: "done", stopReason: "tool_use", endTurn: false }); + kill(); + break; + } if (terminalEmitted) break; } } catch (err) { @@ -296,6 +455,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise undefined); + } } // Reap the process so no zombie is left behind (§三十): wait for the real `close`, and diff --git a/tests/providers/codebuddy-mcp-server.test.ts b/tests/providers/codebuddy-mcp-server.test.ts new file mode 100644 index 00000000000..b669bfb35be --- /dev/null +++ b/tests/providers/codebuddy-mcp-server.test.ts @@ -0,0 +1,161 @@ +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"], + }, + }]); + + await expect(client.callTool({ + name: "not-advertised", + arguments: {}, + })).rejects.toThrow("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); + } + }); +}); 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..aeafefd26c5 --- /dev/null +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { existsSync } from "node:fs"; +import { dirname } 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("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("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 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..cf8b212059b --- /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"); + }); +}); From 01f30c81fec975784f9b9a57f32f984dcbb02472 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sat, 19 Sep 2026 17:07:00 +0800 Subject: [PATCH 02/11] fix(codebuddy): carry pre-result usage into tool-bridge turns A capture-only tool-bridge leg is terminated at message_stop while the CLI parks on the never-answering MCP server, so no result frame ever arrives and the completed response reported zero tokens. Fold message_delta and assistant usage snapshots into per-turn parse state (per-field maxima, result frames stay authoritative) and attach the folded snapshot to the synthesized done(tool_use) event. --- src/adapters/coding-agent/protocol.ts | 60 +++++++++++++++++-- src/adapters/coding-agent/turn.ts | 9 ++- tests/providers/codebuddy-protocol.test.ts | 36 +++++++++++ .../codebuddy-tool-bridge-turn.test.ts | 24 ++++++++ 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index ca9d9d7b118..c76db101f45 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -138,10 +138,8 @@ 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; @@ -157,6 +155,47 @@ export function usageFromResult(message: StreamMessage): OcxUsage | 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); + 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, + ...(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. @@ -170,6 +209,8 @@ export interface StreamParseState { 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; } /** @@ -192,7 +233,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); @@ -207,6 +249,7 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa } } } + observePartialUsage(state, messageRecord?.usage); return events; } @@ -320,6 +363,13 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt 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; } diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 5c283d234a7..9efb56ae2a0 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -440,7 +440,14 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { test("usageFromResult returns undefined when no usage is present", () => { expect(usageFromResult({ type: "result" })).toBeUndefined(); }); + + 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 }); + // 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 index aeafefd26c5..44751378aa4 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -152,6 +152,30 @@ describe("CodeBuddy capture-only tool bridge turn", () => { 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("an init frame without the bridge server fails closed", async () => { const adapter = createCodeBuddyAdapter(provider(), { spawn: () => fakeChild(frameLines([INIT_EMPTY])) as unknown as ChildProcess, From 9e7fd5863483ade1aec0aa7193d19c0a413f684d Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sat, 19 Sep 2026 17:07:00 +0800 Subject: [PATCH 03/11] test(codebuddy): add the opt-in live acceptance harness Port the three-turn synthetic acceptance scenario (function_call capture, continuation after tool results, exact final text) onto the current tree: the provider is seeded from the registry entry with a CODEBUDDY_LIVE_API_KEY, the region is selectable via CODEBUDDY_LIVE_REGION, the CLI installation is pinned by front-loading CODEBUDDY_LIVE_CLI_PATH on PATH and failing closed on a resolution mismatch, and each tool leg must report positive usage so the partial-usage path cannot regress silently. The harness stays opt-in (CODEBUDDY_LIVE_TEST=1), runs outside the bun test preload, keeps real HOME for the CLI, and prints only fixed-code results. --- scripts/codebuddy-live-acceptance.ts | 406 ++++++++++++++++++ scripts/test-layout/layout.json | 4 + tests/fixtures/test-layout-expected.json | 4 + .../codebuddy-live-acceptance.test.ts | 294 +++++++++++++ 4 files changed, 708 insertions(+) create mode 100644 scripts/codebuddy-live-acceptance.ts create mode 100644 tests/providers/codebuddy-live-acceptance.test.ts diff --git a/scripts/codebuddy-live-acceptance.ts b/scripts/codebuddy-live-acceptance.ts new file mode 100644 index 00000000000..6f1815e3707 --- /dev/null +++ b/scripts/codebuddy-live-acceptance.ts @@ -0,0 +1,406 @@ +/** + * 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(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. + */ +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 7117b4679ea..06ac5b3b3e4 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/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1fd592857c0..32c97bfd925 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-live-acceptance.test.ts b/tests/providers/codebuddy-live-acceptance.test.ts new file mode 100644 index 00000000000..d9c20e463d6 --- /dev/null +++ b/tests/providers/codebuddy-live-acceptance.test.ts @@ -0,0 +1,294 @@ +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 } = {}): { + 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" }); + return sse(options.omitUsage + ? raw.replace(/"usage":\{[^}]*\},/, "") + : raw); + } + if (body.previous_response_id === "resp_for_call_lookup") { + return sse(toolTurn("fc_reserve", "call_reserve", "reserve_inventory", { sku: "TEST-123", quantity: 2 })); + } + return sse(textTurn("msg_final", "R-42")); + }; + 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 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"); + }); +}); From 6057e3ff94d4f746ca494a63d832cda05ed89228 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sat, 19 Sep 2026 17:07:00 +0800 Subject: [PATCH 04/11] docs(codebuddy): document the capture-only tool bridge Describe the armed path in the provider guide: catalog and MCP config in a private temp dir, exact --allowedTools, init-handshake validation, captured function_call items with wire-name mapping, the 16-call turn cap, message_stop termination, and client-owned approval and execution. Update the registry notes that still described the provider as text-only until a bridge lands. --- docs-site/src/content/docs/guides/providers.md | 2 +- src/providers/registry/entries-extended.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e8cba6c928d..74bb7fc3339 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -831,7 +831,7 @@ 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. ### Official Qoder CLI (Global & CN) 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", From a1fd16a56b9d7126be25e2a1676107ce777f1424 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sat, 19 Sep 2026 21:06:31 +0800 Subject: [PATCH 05/11] fix(codebuddy): enforce the tool contract and reap the bridge cleanly Review follow-ups on the capture-only tool-bridge PR: - tool_choice required|named was validated but never enforced: a text result on a required turn still became a successful done(stop). The bridge input now carries requireToolCall and a terminal text result with no captured call fails closed as a stable tool_call_required upstream error (auto/none behavior unchanged). - A capture-only tool leg is terminated at message_stop before any result frame, so input tokens reported in message_start.message.usage were lost and the leg underreported usage. message_start now feeds the partial fold, and the live harness asserts both token directions instead of either. - The capture MCP server is the CLI's child and the pinned SDK (1.30.0) does not detect stdin EOF, so it could outlive the terminated CLI as an orphaned bun process. It now exits when stdin ends or closes; the kill ladder reaps the tree through the pipe, and the regression test proves the server exits on stdin close. - A synchronous spawn throw skipped the event-loop cleanup and leaked the private ocx-coding-agent-tools-* temp dir; that path now removes it too. - The docs disclose the pending CodeBuddy AUP/security review next to the tool-bridge description, and two stale comments from the tools-disabled era are corrected. --- .../src/content/docs/guides/providers.md | 1 + scripts/codebuddy-live-acceptance.ts | 4 +- src/adapters/codebuddy/adapter.ts | 6 +- src/adapters/codebuddy/mcp-server.ts | 10 +++ src/adapters/coding-agent/protocol.ts | 16 ++++- src/adapters/coding-agent/turn.ts | 35 ++++++++++ .../codebuddy-live-acceptance.test.ts | 21 ++++-- tests/providers/codebuddy-mcp-server.test.ts | 23 +++++++ tests/providers/codebuddy-protocol.test.ts | 7 ++ .../codebuddy-tool-bridge-turn.test.ts | 65 ++++++++++++++++++- tests/providers/codebuddy-tool-bridge.test.ts | 2 +- 11 files changed, 179 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 74bb7fc3339..19d9c3ea51f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -833,6 +833,7 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C - **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 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. +- **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 index 6f1815e3707..90653c55da5 100644 --- a/scripts/codebuddy-live-acceptance.ts +++ b/scripts/codebuddy-live-acceptance.ts @@ -195,12 +195,14 @@ export async function readResponseStream(response: Response, signal: AbortSignal /** * 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"); + requireCondition(inputTokens > 0 && outputTokens > 0, "usage_zero"); } export function argumentsMatch(actual: unknown, expected: RecordValue): boolean { diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index c0ce682713b..731eda82503 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -60,8 +60,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, @@ -128,6 +129,7 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu tools: toolBridge.tools, emittedNameMap: toolBridge.emittedNameMap, maxTurnToolCalls: CODEBUDDY_TOOL_LIMITS.maxTurnToolCalls, + requireToolCall: toolBridge.requireToolCall, } : undefined; await runCodingAgentTurn({ diff --git a/src/adapters/codebuddy/mcp-server.ts b/src/adapters/codebuddy/mcp-server.ts index f92b7a91316..c585467353a 100644 --- a/src/adapters/codebuddy/mcp-server.ts +++ b/src/adapters/codebuddy/mcp-server.ts @@ -156,6 +156,16 @@ async function loadTools(path: string): Promise { 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)); diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index c76db101f45..5ba659e723a 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -328,8 +328,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 }); } @@ -363,6 +364,17 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt 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. diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 9efb56ae2a0..129aa82fb88 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -122,6 +122,12 @@ export interface CodingAgentToolBridgeInput { 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; } /** @@ -261,6 +267,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise undefined); return; } @@ -305,6 +314,9 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { try { child.kill("SIGKILL"); } catch { /* already gone */ } @@ -419,6 +431,29 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise; turnId: string | null } -function scenarioFetch(options: { omitUsage?: boolean } = {}): { +function scenarioFetch(options: { omitUsage?: boolean; usageInputOnly?: boolean } = {}): { fetch: (input: URL, init: RequestInit) => Promise; requests: ScenarioRequest[]; } { @@ -107,9 +107,11 @@ function scenarioFetch(options: { omitUsage?: boolean } = {}): { requests.push({ body, turnId: metadata }); if (!body.previous_response_id) { const raw = toolTurn("fc_lookup", "call_lookup", "lookup_inventory", { sku: "TEST-123" }); - return sse(options.omitUsage - ? raw.replace(/"usage":\{[^}]*\},/, "") - : raw); + 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 })); @@ -280,6 +282,17 @@ describe("CodeBuddy live acceptance harness", () => { ))).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 mention of the expected marker is not a semantic pass", async () => { let count = 0; const mockFetch = async () => { diff --git a/tests/providers/codebuddy-mcp-server.test.ts b/tests/providers/codebuddy-mcp-server.test.ts index b669bfb35be..049128b83eb 100644 --- a/tests/providers/codebuddy-mcp-server.test.ts +++ b/tests/providers/codebuddy-mcp-server.test.ts @@ -158,4 +158,27 @@ describe("CodeBuddy capture-only MCP server", () => { 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 287e2615854..fb692755c41 100644 --- a/tests/providers/codebuddy-protocol.test.ts +++ b/tests/providers/codebuddy-protocol.test.ts @@ -280,6 +280,13 @@ describe("codebuddy stream-json event mapping", () => { 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 } }, diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index 44751378aa4..e93175863dc 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname } from "node:path"; import { Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; @@ -176,6 +177,68 @@ describe("CodeBuddy capture-only tool bridge turn", () => { }); }); + test("a tool-bridge turn records input tokens from message_start", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(p); + const 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("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, diff --git a/tests/providers/codebuddy-tool-bridge.test.ts b/tests/providers/codebuddy-tool-bridge.test.ts index cf8b212059b..ccdeccba405 100644 --- a/tests/providers/codebuddy-tool-bridge.test.ts +++ b/tests/providers/codebuddy-tool-bridge.test.ts @@ -5,7 +5,7 @@ import { buildCodeBuddyToolBridge, codeBuddyToolAlias, } from "../../src/adapters/codebuddy/tool-bridge"; -import type { OcxParsedRequest, OcxTool, OcxToolChoice } from "../src/types"; +import type { OcxParsedRequest, OcxTool, OcxToolChoice } from "../../src/types"; function tool( name: string, From 2ddf3e6083df8ee0346ace13fab7c14eacdbc8d1 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sat, 19 Sep 2026 22:23:46 +0800 Subject: [PATCH 06/11] fix(codebuddy): validate tool call completion and stream termination Address CodeRabbit review findings on the tool bridge: - Require every started tool call to complete before message_stop (turn.ts); mismatched start/stop counts fail closed with a 502 protocol_error. - Require the [DONE] SSE terminator in the live acceptance stream validator before accepting a completed response (scripts/codebuddy-live-acceptance.ts). - Document 502 tool_call_required failure behavior under tool_choice: required in the provider guide (docs-site). - Add regression test cases for incomplete tool calls and truncated streams. --- .../src/content/docs/guides/providers.md | 1 + scripts/codebuddy-live-acceptance.ts | 1 + src/adapters/coding-agent/turn.ts | 18 +++++++++++++++ .../codebuddy-live-acceptance.test.ts | 13 +++++++++-- .../codebuddy-tool-bridge-turn.test.ts | 22 +++++++++++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 19d9c3ea51f..1dd4d25676c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -833,6 +833,7 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C - **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 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 index 90653c55da5..7ef7c051412 100644 --- a/scripts/codebuddy-live-acceptance.ts +++ b/scripts/codebuddy-live-acceptance.ts @@ -180,6 +180,7 @@ export async function readResponseStream(response: Response, signal: AbortSignal 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"); diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 129aa82fb88..ffa11d58992 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -459,6 +459,24 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 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) { if (!initValidated) { emitOnce({ diff --git a/tests/providers/codebuddy-live-acceptance.test.ts b/tests/providers/codebuddy-live-acceptance.test.ts index 0b80fb511f6..dba23bc544a 100644 --- a/tests/providers/codebuddy-live-acceptance.test.ts +++ b/tests/providers/codebuddy-live-acceptance.test.ts @@ -96,7 +96,7 @@ function textTurn(id: string, text: string): string { interface ScenarioRequest { body: Record; turnId: string | null } -function scenarioFetch(options: { omitUsage?: boolean; usageInputOnly?: boolean } = {}): { +function scenarioFetch(options: { omitUsage?: boolean; usageInputOnly?: boolean; omitDone?: boolean } = {}): { fetch: (input: URL, init: RequestInit) => Promise; requests: ScenarioRequest[]; } { @@ -116,7 +116,7 @@ function scenarioFetch(options: { omitUsage?: boolean; usageInputOnly?: boolean if (body.previous_response_id === "resp_for_call_lookup") { return sse(toolTurn("fc_reserve", "call_reserve", "reserve_inventory", { sku: "TEST-123", quantity: 2 })); } - return sse(textTurn("msg_final", "R-42")); + const finalTurn = textTurn("msg_final", "R-42"); return sse(options.omitDone ? finalTurn.replace("data: [DONE]\n\n", "") : finalTurn); }; return { fetch, requests }; } @@ -293,6 +293,15 @@ describe("CodeBuddy live acceptance harness", () => { ))).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 () => { diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index e93175863dc..1056c3cb562 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -199,6 +199,28 @@ describe("CodeBuddy capture-only tool bridge turn", () => { }); }); + test("message_stop with an incomplete tool call fails with protocol_error", async () => { + const p = parsed([tool("exec")]); + const bridge = buildCodeBuddyToolBridge(p); + const 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("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"]; From afef8090035f83c80e95ccb68406e6ce5d5e3087 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sun, 20 Sep 2026 00:22:41 +0800 Subject: [PATCH 07/11] fix(codebuddy): reject an incomplete tool turn that ends via a result frame The incomplete-call check only ran after message_stop, so a stream that delivered the terminal result frame first (or without message_stop) emitted done before the check could run: under tool_choice auto an unfinished call still succeeded, and under required only the zero-completed case was rejected. Intercept the done event from a result frame the same way the tool_call_required check does: when started calls do not equal completed calls, fail closed with the 502 protocol_error shape and add the reordered-stream regression test. --- src/adapters/coding-agent/turn.ts | 25 +++++++++++++++++++ .../codebuddy-tool-bridge-turn.test.ts | 24 ++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index ffa11d58992..113d48dcc6c 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -454,6 +454,31 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 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; + } emitOnce(event.type === "error" ? { ...event, message: redactSecrets(event.message, profile.tokenEnv, apiKey) } : event); diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index 1056c3cb562..40829fcbe8c 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -221,6 +221,30 @@ describe("CodeBuddy capture-only tool bridge turn", () => { 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("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"]; From 907b6cd628b8b8a41846386b861b428a3553760b Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sun, 20 Sep 2026 20:20:55 +0800 Subject: [PATCH 08/11] fix(codebuddy): defer an early result frame to the message_stop synthesis When every captured tool call has completed and the CLI settles with a successful result frame before message_stop (instead of parking on the never-answering capture server), the adapter emitted the result-derived done(stop) immediately: terminalEmitted was set, the loop exited, and the synthesized done(tool_use, endTurn: false) the client contract expects never surfaced. Defer that terminal event instead: message_stop synthesis emits the tool_use completion with the deferred result frame's usage (authoritative vendor accounting) folded in, and a stream that ends without message_stop fails closed with a 502 protocol_error. Regression coverage added for both paths. Also rebased onto current dev, resolving the tool-bridge turn.ts conflicts by combining dev's Windows taskkill tree termination with the stdin-EOF reap path. --- src/adapters/coding-agent/turn.ts | 34 +++++++++- .../codebuddy-tool-bridge-turn.test.ts | 63 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 113d48dcc6c..b81bf510182 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -352,6 +352,12 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise | undefined; const state: StreamParseState = { sawPartialText: false, sawPartialThinking: false, @@ -479,6 +485,22 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 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); @@ -520,11 +542,12 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { 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"]; From 9671f00abd064d6f2b7b0c1179e43d07453f3d96 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sun, 20 Sep 2026 20:34:23 +0800 Subject: [PATCH 09/11] fix(codebuddy): reject tool calls that arrive before bridge init The tool_call_start handler counted and forwarded tool lifecycle events before initValidated was set. A stream could emit a complete tool call, then a valid system/init, then message_stop: the late init flipped the flag, the delayed message_stop check passed, and the adapter accepted a turn whose tool events surfaced from an unvalidated bridge. Require initValidated before the first tool call: a tool_call_start on an unvalidated bridge fails closed immediately with the established tool_bridge_init_missing error, and no tool lifecycle event reaches the client. Regression coverage added for the tool-call-before-init ordering. --- src/adapters/coding-agent/turn.ts | 18 +++++++++++++ .../codebuddy-tool-bridge-turn.test.ts | 26 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index b81bf510182..f20cc99e65e 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -406,6 +406,24 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise toolBridge.maxTurnToolCalls) { emitOnce({ diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index ff6678a5e7a..45180fde4ce 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -245,6 +245,32 @@ describe("CodeBuddy capture-only tool bridge turn", () => { 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); From 8028b607f06e19343c8b58dc2eadf7394ba6f784 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Mon, 21 Sep 2026 23:52:46 +0800 Subject: [PATCH 10/11] fix(codebuddy): harden tool-bridge usage snapshots and pre-init tool calls Keep cache-creation-only usage snapshots instead of collapsing them to undefined (a capture-only tool leg ends at message_stop with no result frame, so that snapshot is the only accounting the turn sees), and refuse a tool call that arrives before the bridge init handshake at arrival time so a later init frame cannot retroactively legitimize it. --- src/adapters/coding-agent/protocol.ts | 14 ++++++-- src/adapters/coding-agent/turn.ts | 26 +++++--------- tests/providers/codebuddy-protocol.test.ts | 36 +++++++++++++++++++ .../codebuddy-tool-bridge-turn.test.ts | 26 ++++++++++++++ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 5ba659e723a..d4e5fd7298c 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -145,13 +145,21 @@ function usageFromAnthropicShape(usage: Record): OcxUsage | und 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, - ...(cachedInputTokens !== undefined ? { cachedInputTokens, cacheReadInputTokens: cachedInputTokens } : {}), - ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), + ...(cacheReadTotal > 0 ? { cachedInputTokens: cacheReadTotal, cacheReadInputTokens: cacheReadTotal } : {}), + ...(cacheCreationTotal > 0 ? { cacheCreationInputTokens: cacheCreationTotal } : {}), }; } diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index f20cc99e65e..f9522b6e15b 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -406,15 +406,15 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 0) { - if (!initValidated) { - emitOnce({ - type: "error", - message: "Coding-agent tool bridge init frame was not observed before the first tool call.", - status: 502, - errorType: "upstream_error", - code: "tool_bridge_init_missing", - retryable: false, - }); - kill(); - break; - } + // 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. diff --git a/tests/providers/codebuddy-protocol.test.ts b/tests/providers/codebuddy-protocol.test.ts index fb692755c41..3cf7690156b 100644 --- a/tests/providers/codebuddy-protocol.test.ts +++ b/tests/providers/codebuddy-protocol.test.ts @@ -252,6 +252,42 @@ describe("codebuddy stream-json event mapping", () => { 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. diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index 45180fde4ce..48b12fb53e0 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -383,6 +383,32 @@ describe("CodeBuddy capture-only tool bridge turn", () => { 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([ From 4701f6ef269036ce2f149700a87b14be841dfeeb Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Mon, 21 Sep 2026 23:52:53 +0800 Subject: [PATCH 11/11] fix(bridge): stop streaming unparseable tool args and repair poisoned history envelopes A coding-agent stream that loses the leading brace-quote of a tool-call arguments JSON can never assemble into parseable JSON. Hold those fragments instead of streaming them, so the failed item never publishes bytes the client would retain and replay as poisoned history; at completion the existing fail-closed still turns the turn into a clean 502. For history already carrying the corruption (observed live 260921 as arguments 'code":"...'), parseRequest now repairs the closed object envelope when the restored text parses, so the model sees the real call instead of a tolerated {} forever. --- src/bridge/internal.ts | 20 ++++++++++++++ src/bridge/sse.ts | 17 ++++++++---- src/responses/parser.ts | 33 +++++++++++++++++++++++- tests/adapters/bridge.test.ts | 30 +++++++++++++++++++++ tests/responses/responses-parser.test.ts | 32 +++++++++++++++++++++++ 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/bridge/internal.ts b/src/bridge/internal.ts index aedf81bde3e..cd126ca60aa 100644 --- a/src/bridge/internal.ts +++ b/src/bridge/internal.ts @@ -114,6 +114,26 @@ export function toolCallArgumentsUsable(args: string): boolean { } } +/** + * Whether an in-progress function-call argument buffer could still become valid JSON. + * The first non-whitespace byte must be one that can begin a JSON value. A stream that + * already lost its leading `{"` (observed from coding-agent CLIs as `code":"…}`) can only + * fail `toolCallArgumentsUsable` at completion, so streaming those fragments publishes + * bytes a failed item cannot take back — the same #765 rule that refuses completion. + */ +export function toolCallArgumentsCouldBeJson(args: string): boolean { + const first = args.trimStart().charAt(0); + if (first === "") return true; + return first === "{" + || first === "[" + || first === "\"" + || first === "-" + || (first >= "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/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/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/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)", () => {