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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -831,8 +831,10 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C
- Global: [CodeBuddy Global API Keys](https://www.codebuddy.ai/profile/keys)
- CN: [CodeBuddy CN API Keys](https://copilot.tencent.com/profile/keys)
- **Region Isolation:** `codebuddy` and `codebuddy-cn` use separate canonical endpoints (`https://www.codebuddy.ai` and `https://www.codebuddy.cn`) and isolated child environments (`CODEBUDDY_INTERNET_ENVIRONMENT=public` vs `internal`). Credentials are strictly region-scoped and never exchanged across environments. Overriding the canonical base URL fails closed.
- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text.
- **Tool Ownership and the Tool Bridge:** The CLI is always spawned with `--tools ""` and `--strict-mcp-config`, so it has no built-in or user-configured tools of its own. When a request carries a Codex tool catalog, the provider arms a capture-only MCP bridge: the validated catalog and MCP config are written to a private temp dir, the CLI is launched with `--mcp-config` and an exact `--allowedTools` list, and the `system/init` frame must report exactly that bridge server as connected or the turn fails closed. The bridge advertises the Codex tools and captures proposed calls but never executes anything: a completed tool-call batch is returned as `function_call` items (names mapped back to the request's wire names, at most 16 calls per assistant message), the process tree is terminated at `message_stop`, and the external Codex client alone performs approval, sandboxing, and execution. Tool results come back as the next request's input, and the conversation continues. Requests without tools keep the plain text-and-reasoning shape. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text.
- **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement.
- **Tool Choice Enforcement:** When a request specifies `tool_choice: "required"` or selects a specific named tool, the bridge expects a tool call from the model. If the CLI completes the turn with plain text instead of capturing a tool call, OpenCodex fails closed with a 502 `tool_call_required` error rather than returning an invalid text completion.
- **Governance Status:** Whether routing this vendor automation surface behind a proxy for a third-party agent satisfies CodeBuddy's acceptable-use terms is an open question flagged for maintainer security review (see the governance note in the provider registry entry). Treat this provider as pending that review, and keep the tool bridge's ownership boundary in mind: the nested CLI advertises tools but never executes them, and approval, sandboxing, and execution remain with the external Codex client.

### Official Qoder CLI (Global & CN)

Expand Down
409 changes: 409 additions & 0 deletions scripts/codebuddy-live-acceptance.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 73 additions & 6 deletions src/adapters/codebuddy/adapter.ts
Original file line number Diff line number Diff line change
@@ -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 (§六/§十四).
*
Expand All @@ -32,10 +60,16 @@ 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, parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] {
export function buildArgs(
profile: CodeBuddyProfile,
parsed: OcxParsedRequest,
provider: OcxProviderConfig,
toolBridge?: Pick<CodeBuddyToolBridge, "tools">,
): string[] {
const args: string[] = [
"-p",
"--output-format", "stream-json",
Expand All @@ -50,8 +84,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;
Expand All @@ -71,13 +108,43 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu
},

async runTurn(parsed, incoming, emit): Promise<void> {
let toolBridge: CodeBuddyToolBridge;
try {
toolBridge = buildCodeBuddyToolBridge(parsed);
} catch (err) {
emit({
type: "error",
message: `Invalid CodeBuddy tool catalog: ${err instanceof Error ? err.message : String(err)}`,
status: 400,
errorType: "invalid_request_error",
code: "tool_catalog_invalid",
retryable: false,
});
return;
}
const bridgeInput: CodingAgentToolBridgeInput | undefined = toolBridge.tools.length > 0
? {
serverName: CODEBUDDY_MCP_SERVER_NAME,
serverModulePath: CODEBUDDY_MCP_SERVER_PATH,
tools: toolBridge.tools,
emittedNameMap: toolBridge.emittedNameMap,
maxTurnToolCalls: CODEBUDDY_TOOL_LIMITS.maxTurnToolCalls,
requireToolCall: toolBridge.requireToolCall,
}
: undefined;
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,
});
Expand Down
187 changes: 187 additions & 0 deletions src/adapters/codebuddy/mcp-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Isolated MCP catalog used by the CodeBuddy adapter.
*
* This process advertises the current Codex tool schemas but deliberately never
* executes a call. The parent adapter captures CodeBuddy's completed `tool_use`
* frame, terminates this process tree, and returns the call to the Codex host,
* where the normal approval and sandbox boundary remains authoritative.
*/

import { open } from "node:fs/promises";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { CODEBUDDY_TOOL_LIMITS } from "./tool-bridge";

interface ToolDefinition {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}

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<string, unknown> {
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<Buffer> {
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<string, unknown>): 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<ToolDefinition[]> {
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<string>();
return parsed.map((value): ToolDefinition => {
if (
!isRecord(value)
|| typeof value.name !== "string"
|| !MCP_TOOL_NAME_PATTERN.test(value.name)
|| utf8Bytes(value.name) > CODEBUDDY_TOOL_LIMITS.maxNameBytes
|| typeof value.description !== "string"
|| value.description.length < 1
|| hasUnpairedSurrogate(value.description)
|| INVALID_DESCRIPTION_CONTROL_PATTERN.test(value.description)
|| utf8Bytes(value.description) > CODEBUDDY_TOOL_LIMITS.maxDescriptionBytes
|| !isRecord(value.inputSchema)
) {
throw new Error("tool catalog contains an invalid definition");
}
if (names.has(value.name)) throw new Error("tool catalog contains duplicate names");
names.add(value.name);
assertBoundedSchema(value.inputSchema);
const definition = {
name: value.name,
description: value.description,
inputSchema: value.inputSchema,
};
if (utf8Bytes(JSON.stringify(definition)) > CODEBUDDY_TOOL_LIMITS.maxToolBytes) {
throw new Error("tool catalog contains an oversized definition");
}
return definition;
});
}

const catalogPath = process.argv[2];
if (!catalogPath) throw new Error("missing tool catalog");

// Exit when stdin closes. The MCP stdio binding expects servers to exit on stdin EOF, and the
// pinned SDK (1.30.0) does not detect EOF itself: without this, the capture server would outlive
// the CLI it serves — whenever the parent terminates the CLI (message_stop capture path, timeout,
// crash), the pipe's write end closes, and this server must follow instead of lingering as an
// orphaned bun process parked on the never-answering CallTool promise.
const exitOnStdinClose = (): void => process.exit(0);
process.stdin.on("end", exitOnStdinClose);
process.stdin.on("close", exitOnStdinClose);

const tools = await loadTools(catalogPath);
const advertisedNames = new Set(tools.map(tool => tool.name));

const server = new Server(
{ name: "opencodex-codebuddy-capture", version: "1.0.0" },
{ capabilities: { tools: {} } },
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(CallToolRequestSchema, async request => {
if (!advertisedNames.has(request.params.name)) {
throw new Error("unknown isolated tool");
}
// A pending Promise does not execute anything and keeps the CodeBuddy turn
// parked until the parent has captured message_stop and terminates the tree.
return await new Promise<never>(() => {});
});

await server.connect(new StdioServerTransport());
Loading
Loading