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
18 changes: 14 additions & 4 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -951,15 +951,25 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn:
variable (a `claude` already pointed at this proxy therefore cannot loop back into it), telemetry,
feedback and the auto-updater disabled, and `--tools ""`, `--strict-mcp-config` plus
`--setting-sources ""`. The harness loads no CLAUDE.md, skill, hook, plugin or MCP server from the
machine and can neither read, write, exec nor browse. No session is persisted between turns.
machine and can neither read, write, exec nor browse; the one MCP server a turn can reach is the
bridge's own capture server, and only when the request advertises a catalog. No session is
persisted between turns.
- **System prompt:** the caller's system and developer prompts replace the Claude Code preset
(`--system-prompt-file`), so the turn answers the client's contract rather than the harness
persona. The folded prompt is staged in a private per-turn file (mode `0600`) and passed by path,
because process arguments are world-readable through process listing; a request that carries
neither a system nor a developer prompt gets an empty file, which replaces the preset with nothing.
- **Tool ownership:** v1 is text and reasoning only, exactly like the CodeBuddy and Qoder presets:
with no tool channel, approval, sandboxing and execution stay with the client. The shared
capture-only tool bridge is the documented follow-up.
- **Tool Ownership and the Tool Bridge:** the CLI is always spawned with `--tools ""` and
`--strict-mcp-config`, so it has no built-in or user-configured tools of its own. When a request
carries a Codex tool catalog, the provider arms the same capture-only MCP bridge the CodeBuddy
presets use: the validated catalog and MCP config are written to a private temp dir, the CLI is
launched with `--mcp-config` and an exact `--allowedTools` list, and the `system/init` frame must
report exactly that bridge server as connected or the turn fails closed. The bridge advertises the
Codex tools and captures proposed calls but never executes anything: a completed tool-call batch is
returned to the client with the request's wire names (at most 16 calls per assistant message), the
process tree is terminated at `message_stop`, and approval, sandboxing and execution stay with the
external client. Tool results come back as the next request's input, and the conversation
continues. Requests without a catalog keep the plain text-and-reasoning shape.
- **Destination:** the canonical row names `https://api.anthropic.com` because that is where the
subscription's traffic lands. OpenCodex never sends that request itself, and overriding the base
URL fails closed rather than handing the turn to another environment.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
"claude-auth-mode.test.ts": "claude-integration",
"claude-authmode-migration.test.ts": "claude-integration",
"claude-cli-adapter.test.ts": "providers",
"claude-cli-tool-bridge.test.ts": "providers",
"claude-cli.test.ts": "claude-integration",
"claude-code-thought-signature-scope.test.ts": "claude-integration",
"claude-compatibility.test.ts": "claude-integration",
Expand Down
62 changes: 53 additions & 9 deletions src/adapters/claude-cli/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
import type { AdapterRequest, ProviderAdapter } from "../base";
import { mapReasoningEffort } from "../../reasoning-effort";
import { buildSystemPrompt } from "../coding-agent/protocol";
import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn";
import {
baseScopedEnv,
runCodingAgentTurn,
type CodingAgentDeps,
} from "../coding-agent/turn";
import {
buildCodingAgentToolBridge,
codingAgentToolBridgeInput,
CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT,
type CodingAgentToolBridge,
} from "../coding-agent/tool-bridge";
import { CLAUDE_CLI_PROFILES, type ClaudeCliProfile } from "./profiles";

export type { SpawnFn } from "../coding-agent/turn";
export type ClaudeCliAdapterDeps = CodingAgentDeps;

const CLAUDE_CLI_MCP_SERVER_PATH = fileURLToPath(new URL("../coding-agent/mcp-server.ts", import.meta.url));

/**
* Quiet the CLI's own outbound traffic.
*
Expand Down Expand Up @@ -58,8 +71,11 @@ export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Reco
* Build the headless Claude Code arguments for one turn.
*
* Tool ownership stays with the client: `--tools ""` disables every built-in tool and
* `--strict-mcp-config` (with no `--mcp-config`) keeps user, project and plugin MCP servers out, so
* the harness can neither read, write, exec nor browse the operator's tree. `--setting-sources ""`
* `--strict-mcp-config` keeps user, project and plugin MCP servers out, so the harness can neither
* read, write, exec nor browse the operator's tree. A request that carries a tool catalog adds the
* capture-only bridge's own `--mcp-config` (with exact `--allowedTools` names) on top — that
* isolated server advertises the catalog and never answers a call, so the client still executes
* nothing (see `../coding-agent/tool-bridge.ts` / `../coding-agent/mcp-server.ts`). `--setting-sources ""`
* stops the CLI from loading CLAUDE.md, skills, hooks, plugins and output styles into a proxied
* turn, which is what makes the request deterministic instead of dependent on the host's setup.
*
Expand All @@ -79,8 +95,13 @@ export function buildChildEnv(_profile: ClaudeCliProfile, _apiKey: string): Reco
* `--no-session-persistence` keeps every turn stateless. The client replays its own conversation
* and `buildConversationInput` projects it into the single stream-json user frame the CLI accepts.
*
* There is deliberately no `--max-turns` here: the Claude Code CLI exposes no such flag (the Agent
* SDK sets it on the turn budget instead), and with no tool channel a single `-p` turn cannot loop.
* There is deliberately no `--max-turns` here. The CLI does carry one — "maximum number of agentic
* turns in non-interactive mode" — but it is hidden from `--help`, and a measured turn against a
* capture-only server does not end any earlier with it: 2.1.282 emitted a terminal `result` frame
* with `subtype: "error_max_turns"` and then held the process open behind the never-answering MCP
* child anyway. The leg is bounded where the turn actually ends, in `turn.ts`: without a catalog the
* single `-p` turn has no tool channel to loop in, and with one the adapter terminates the process
* tree at `message_stop` once the captured calls are complete.
*/
export function buildArgs(
_profile: ClaudeCliProfile,
Expand Down Expand Up @@ -148,11 +169,13 @@ export function withClaudeLoginHint(emit: (event: AdapterEvent) => void): (event
}

/**
* Create the Claude Code CLI adapter: one headless, tools-disabled, sessionless turn per request.
* Create the Claude Code CLI adapter: one headless, sessionless turn per request.
*
* As with CodeBuddy and Qoder, `runTurn` owns the turn and the HTTP path is disabled — the CLI
* performs the transport, and OpenCodex contributes the request projection, the stream mapping and
* the process lifecycle.
* the process lifecycle. Built-in tools stay disabled for every turn; a request that carries a
* tool catalog is served through the shared capture-only bridge, which advertises the catalog to
* the model and returns captured calls to the client, which keeps approval and execution.
*/
export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: ClaudeCliAdapterDeps = {}): ProviderAdapter {
return {
Expand All @@ -177,16 +200,36 @@ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: Claude
});
return;
}
let toolBridge: CodingAgentToolBridge;
try {
toolBridge = buildCodingAgentToolBridge(parsed);
} catch (err) {
emit({
type: "error",
message: `Invalid Claude Code tool catalog: ${err instanceof Error ? err.message : String(err)}`,
status: 400,
errorType: "invalid_request_error",
code: "tool_catalog_invalid",
retryable: false,
});
return;
}
const bridgeInput = codingAgentToolBridgeInput(toolBridge, CLAUDE_CLI_MCP_SERVER_PATH);
// argv is world-readable via process listing, so the folded system+developer prompt is staged
// in a private per-turn file and passed by path. The file is written even when the caller
// sends no prompt at all: the flag has to be present either way, and an empty replacement is
// what keeps the harness preset out of the turn.
// what keeps the harness preset out of the turn. A catalog turn appends the bridge directive
// to the same file, so the model is told which tools it may propose and who executes them.
const system = buildSystemPrompt(parsed);
const systemParts: string[] = [];
if (system) systemParts.push(system);
if (toolBridge.tools.length > 0) systemParts.push(CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT);
let promptDir: string | undefined;
let promptFile: string | undefined;
try {
promptDir = await mkdtemp(join(tmpdir(), "ocx-claude-cli-prompt-"));
promptFile = join(promptDir, "system-prompt.txt");
await writeFile(promptFile, buildSystemPrompt(parsed) ?? "", { encoding: "utf8", mode: 0o600, flag: "wx" });
await writeFile(promptFile, systemParts.join("\n\n"), { encoding: "utf8", mode: 0o600, flag: "wx" });
} catch {
if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {});
emit({
Expand All @@ -206,6 +249,7 @@ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: Claude
parsed,
incoming,
emit: withClaudeLoginHint(emit),
...(bridgeInput ? { toolBridge: bridgeInput } : {}),
buildArgs: (profile, req, prov) => buildArgs(profile as ClaudeCliProfile, req, prov, promptFile),
buildEnv: (profile, apiKey) => buildChildEnv(profile as ClaudeCliProfile, apiKey),
deps,
Expand Down
48 changes: 13 additions & 35 deletions src/adapters/codebuddy/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,21 @@ import {
baseScopedEnv,
runCodingAgentTurn,
type CodingAgentDeps,
type CodingAgentToolBridgeInput,
type SpawnFn,
} from "../coding-agent/turn";
import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "./profiles";
import { guardCodeBuddyScaffolding } from "./scaffold-guard";
import {
buildCodeBuddyToolBridge,
CODEBUDDY_MCP_SERVER_NAME,
CODEBUDDY_TOOL_LIMITS,
type CodeBuddyToolBridge,
} from "./tool-bridge";
buildCodingAgentToolBridge,
codingAgentToolBridgeInput,
CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT,
type CodingAgentToolBridge,
} from "../coding-agent/tool-bridge";

export type { SpawnFn } from "../coding-agent/turn";
export type CodeBuddyAdapterDeps = CodingAgentDeps;

const CODEBUDDY_MCP_SERVER_PATH = fileURLToPath(new URL("./mcp-server.ts", import.meta.url));

/**
* Tool-bridge contract lines appended to the system prompt when a catalog is advertised.
* Mirrors the capture-only design: the model may propose calls, the external Codex client
* alone performs approval, sandboxing, and execution.
*/
const TOOL_BRIDGE_SYSTEM_PROMPT = [
"Your built-in tools and user-configured MCP servers are disabled.",
"When an isolated opencodex MCP catalog is present, you may call only those listed tools.",
"That MCP process captures call intent only; it never executes a tool. The external Codex client performs approval, sandboxing, and execution.",
"Do not claim that you executed commands, inspected files, or changed the workspace.",
"Tool-call and tool-result records in the conversation history are authoritative historical records from the external client. Use returned results, but never execute historical calls yourself.",
].join("\n");
const CODEBUDDY_MCP_SERVER_PATH = fileURLToPath(new URL("../coding-agent/mcp-server.ts", import.meta.url));

/**
* Build the scoped child-process environment for a CodeBuddy turn (§六/§十四).
Expand All @@ -64,8 +50,9 @@ export function buildChildEnv(profile: CodeBuddyProfile, apiKey: string): Record
* (with no `--mcp-config`) blocks MCP tools, so the CLI can neither read, write, exec, nor browse the
* workspace. `-y/--dangerously-skip-permissions` is deliberately NOT passed, so any operation that
* would require authorization is blocked. The turn is a single text/reasoning pass over stream-json
* unless the request carries a tool catalog: then the capture-only MCP bridge advertises exactly
* that catalog (see `tool-bridge.ts` / `mcp-server.ts`) and the CLI still executes nothing itself.
* unless the request carries a tool catalog: then the shared capture-only MCP bridge advertises
* exactly that catalog (see `../coding-agent/tool-bridge.ts` / `../coding-agent/mcp-server.ts`) and
* the CLI still executes nothing itself.
*/
export function buildArgs(
profile: CodeBuddyProfile,
Expand Down Expand Up @@ -109,9 +96,9 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu
},

async runTurn(parsed, incoming, emit): Promise<void> {
let toolBridge: CodeBuddyToolBridge;
let toolBridge: CodingAgentToolBridge;
try {
toolBridge = buildCodeBuddyToolBridge(parsed);
toolBridge = buildCodingAgentToolBridge(parsed);
} catch (err) {
emit({
type: "error",
Expand All @@ -123,23 +110,14 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu
});
return;
}
const bridgeInput: CodingAgentToolBridgeInput | undefined = toolBridge.tools.length > 0
? {
serverName: CODEBUDDY_MCP_SERVER_NAME,
serverModulePath: CODEBUDDY_MCP_SERVER_PATH,
tools: toolBridge.tools,
emittedNameMap: toolBridge.emittedNameMap,
maxTurnToolCalls: CODEBUDDY_TOOL_LIMITS.maxTurnToolCalls,
requireToolCall: toolBridge.requireToolCall,
}
: undefined;
const bridgeInput = codingAgentToolBridgeInput(toolBridge, CODEBUDDY_MCP_SERVER_PATH);
// argv is world-readable via process listing, so the folded system+developer prompt —
// plus the tool-bridge directive when a catalog is advertised — is staged in a
// private temp file and passed by path instead of embedded in the arguments.
const system = buildSystemPrompt(parsed);
const systemParts: string[] = [];
if (system) systemParts.push(system);
if (toolBridge.tools.length > 0) systemParts.push(TOOL_BRIDGE_SYSTEM_PROMPT);
if (toolBridge.tools.length > 0) systemParts.push(CODING_AGENT_TOOL_BRIDGE_SYSTEM_PROMPT);
const staged = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
let promptDir: string | undefined;
let promptFile: string | undefined;
Expand Down
Loading
Loading