diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 6ded68d73..51b686d87 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -26,6 +26,12 @@ You can use Claude CLI **without any config** (Clawdbot ships a built-in default clawdbot agent --message "hi" --model claude-cli/opus-4.5 ``` +Codex CLI also works out of the box: + +```bash +clawdbot agent --message "hi" --model codex-cli/gpt-5.2-codex +``` + If your gateway runs under launchd/systemd and PATH is minimal, add just the command path: @@ -133,7 +139,12 @@ The provider id becomes the left side of your model ref: ## Sessions -- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`). +- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`) or + `sessionArgs` (placeholder `{sessionId}`) when the ID needs to be inserted + into multiple flags. +- If the CLI uses a **resume subcommand** with different flags, set + `resumeArgs` (replaces `args` when resuming) and optionally `resumeOutput` + (for non-JSON resumes). - `sessionMode`: - `always`: always send a session id (new UUID if none stored). - `existing`: only send a session id if one was stored before. @@ -156,6 +167,8 @@ load local files from plain paths (Claude CLI behavior). ## Inputs / outputs - `output: "json"` (default) tries to parse JSON and extract text + session id. +- `output: "jsonl"` parses JSONL streams (Codex CLI `--json`) and extracts the + last agent message plus `thread_id` when present. - `output: "text"` treats stdout as the final response. Input modes: @@ -175,17 +188,33 @@ Clawdbot ships a default for `claude-cli`: - `systemPromptWhen: "first"` - `sessionMode: "always"` +Clawdbot also ships a default for `codex-cli`: + +- `command: "codex"` +- `args: ["exec","--json","--color","never","--sandbox","read-only","--skip-git-repo-check"]` +- `resumeArgs: ["exec","resume","{sessionId}","--color","never","--sandbox","read-only","--skip-git-repo-check"]` +- `output: "jsonl"` +- `resumeOutput: "text"` +- `modelArg: "--model"` +- `imageArg: "--image"` +- `sessionMode: "existing"` + Override only if needed (common: absolute `command` path). ## Limitations -- **No tools** (tool calls are disabled by design). +- **No Clawdbot tools** (the CLI backend never receives tool calls). Some CLIs + may still run their own agent tooling. - **No streaming** (CLI output is collected then returned). - **Structured outputs** depend on the CLI’s JSON format. +- **Codex CLI sessions** resume via text output (no JSONL), which is less + structured than the initial `--json` run. Clawdbot sessions still work + normally. ## Troubleshooting - **CLI not found**: set `command` to a full path. - **Wrong model name**: use `modelAliases` to map `provider/model` → CLI model. -- **No session continuity**: ensure `sessionArg` is set and `sessionMode` is not `none`. +- **No session continuity**: ensure `sessionArg` is set and `sessionMode` is not + `none` (Codex CLI currently cannot resume with JSON output). - **Images ignored**: set `imageArg` (and verify CLI supports file paths). diff --git a/docs/testing.md b/docs/testing.md index 3160a2748..4afcfd408 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -175,12 +175,14 @@ CLAWDBOT_LIVE_TEST=1 CLAWDBOT_LIVE_SETUP_TOKEN=1 CLAWDBOT_LIVE_SETUP_TOKEN_PROFI - Args: `["-p","--output-format","json","--dangerously-skip-permissions"]` - Overrides (optional): - `CLAWDBOT_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-opus-4-5"` + - `CLAWDBOT_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.2-codex"` - `CLAWDBOT_LIVE_CLI_BACKEND_COMMAND="/full/path/to/claude"` - `CLAWDBOT_LIVE_CLI_BACKEND_ARGS='["-p","--output-format","json","--permission-mode","bypassPermissions"]'` - `CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV='["ANTHROPIC_API_KEY","ANTHROPIC_API_KEY_OLD"]'` - `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE=1` to send a real image attachment (paths are injected into the prompt). - `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG="--image"` to pass image file paths as CLI args instead of prompt injection. - `CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE="repeat"` (or `"list"`) to control how image args are passed when `IMAGE_ARG` is set. + - `CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE=1` to send a second turn and validate resume flow. - `CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG=0` to keep Claude CLI MCP config enabled (default disables MCP config with a temporary empty file). Example: diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 2cfb4085e..2462fca16 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -47,6 +47,38 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = { serialize: true, }; +const DEFAULT_CODEX_BACKEND: CliBackendConfig = { + command: "codex", + args: [ + "exec", + "--json", + "--color", + "never", + "--sandbox", + "read-only", + "--skip-git-repo-check", + ], + resumeArgs: [ + "exec", + "resume", + "{sessionId}", + "--color", + "never", + "--sandbox", + "read-only", + "--skip-git-repo-check", + ], + output: "jsonl", + resumeOutput: "text", + input: "arg", + modelArg: "--model", + sessionIdFields: ["thread_id"], + sessionMode: "existing", + imageArg: "--image", + imageMode: "repeat", + serialize: true, +}; + function normalizeBackendKey(key: string): string { return normalizeProviderId(key); } @@ -76,11 +108,16 @@ function mergeBackendConfig( new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]), ), sessionIdFields: override.sessionIdFields ?? base.sessionIdFields, + sessionArgs: override.sessionArgs ?? base.sessionArgs, + resumeArgs: override.resumeArgs ?? base.resumeArgs, }; } export function resolveCliBackendIds(cfg?: ClawdbotConfig): Set { - const ids = new Set([normalizeBackendKey("claude-cli")]); + const ids = new Set([ + normalizeBackendKey("claude-cli"), + normalizeBackendKey("codex-cli"), + ]); const configured = cfg?.agents?.defaults?.cliBackends ?? {}; for (const key of Object.keys(configured)) { ids.add(normalizeBackendKey(key)); @@ -102,6 +139,12 @@ export function resolveCliBackendConfig( if (!command) return null; return { id: normalized, config: { ...merged, command } }; } + if (normalized === "codex-cli") { + const merged = mergeBackendConfig(DEFAULT_CODEX_BACKEND, override); + const command = merged.command?.trim(); + if (!command) return null; + return { id: normalized, config: { ...merged, command } }; + } if (!override) return null; const command = override.command?.trim(); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 27346a0ef..09f7849e4 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -178,7 +178,10 @@ function toUsage(raw: Record): CliUsage | undefined { : undefined; const input = pick("input_tokens") ?? pick("inputTokens"); const output = pick("output_tokens") ?? pick("outputTokens"); - const cacheRead = pick("cache_read_input_tokens") ?? pick("cacheRead"); + const cacheRead = + pick("cache_read_input_tokens") ?? + pick("cached_input_tokens") ?? + pick("cacheRead"); const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite"); const total = pick("total_tokens") ?? pick("total"); if (!input && !output && !cacheRead && !cacheWrite && !total) @@ -246,6 +249,47 @@ function parseCliJson( return { text: text.trim(), sessionId, usage }; } +function parseCliJsonl( + raw: string, + backend: CliBackendConfig, +): CliOutput | null { + const lines = raw + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) return null; + let sessionId: string | undefined; + let usage: CliUsage | undefined; + const texts: string[] = []; + for (const line of lines) { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!isRecord(parsed)) continue; + if (!sessionId) sessionId = pickSessionId(parsed, backend); + if (!sessionId && typeof parsed.thread_id === "string") { + sessionId = parsed.thread_id.trim(); + } + if (isRecord(parsed.usage)) { + usage = toUsage(parsed.usage) ?? usage; + } + const item = isRecord(parsed.item) ? parsed.item : null; + if (item && typeof item.text === "string") { + const type = + typeof item.type === "string" ? item.type.toLowerCase() : ""; + if (!type || type.includes("message")) { + texts.push(item.text); + } + } + } + const text = texts.join("\n").trim(); + if (!text) return null; + return { text, sessionId, usage }; +} + function resolveSystemPromptUsage(params: { backend: CliBackendConfig; isNewSession: boolean; @@ -328,21 +372,33 @@ async function writeCliImages( function buildCliArgs(params: { backend: CliBackendConfig; + baseArgs: string[]; modelId: string; sessionId?: string; systemPrompt?: string | null; imagePaths?: string[]; promptArg?: string; + useResume: boolean; }): string[] { - const args: string[] = [...(params.backend.args ?? [])]; - if (params.backend.modelArg && params.modelId) { + const args: string[] = [...params.baseArgs]; + if (!params.useResume && params.backend.modelArg && params.modelId) { args.push(params.backend.modelArg, params.modelId); } - if (params.systemPrompt && params.backend.systemPromptArg) { + if ( + !params.useResume && + params.systemPrompt && + params.backend.systemPromptArg + ) { args.push(params.backend.systemPromptArg, params.systemPrompt); } - if (params.sessionId && params.backend.sessionArg) { - args.push(params.backend.sessionArg, params.sessionId); + if (!params.useResume && params.sessionId) { + if (params.backend.sessionArgs && params.backend.sessionArgs.length > 0) { + for (const entry of params.backend.sessionArgs) { + args.push(entry.replaceAll("{sessionId}", params.sessionId)); + } + } else if (params.backend.sessionArg) { + args.push(params.backend.sessionArg, params.sessionId); + } } if (params.imagePaths && params.imagePaths.length > 0) { const mode = params.backend.imageMode ?? "repeat"; @@ -434,8 +490,19 @@ export async function runCliAgent(params: { backend, cliSessionId: params.cliSessionId, }); - const sessionIdSent = - backend.sessionArg && cliSessionIdToSend ? cliSessionIdToSend : undefined; + const useResume = Boolean( + params.cliSessionId && + cliSessionIdToSend && + backend.resumeArgs && + backend.resumeArgs.length > 0, + ); + const sessionIdSent = cliSessionIdToSend + ? useResume || + Boolean(backend.sessionArg) || + Boolean(backend.sessionArgs?.length) + ? cliSessionIdToSend + : undefined + : undefined; const systemPromptArg = resolveSystemPromptUsage({ backend, isNewSession: isNew, @@ -459,13 +526,23 @@ export async function runCliAgent(params: { prompt, }); const stdinPayload = stdin ?? ""; + const baseArgs = useResume + ? (backend.resumeArgs ?? backend.args ?? []) + : (backend.args ?? []); + const resolvedArgs = useResume + ? baseArgs.map((entry) => + entry.replaceAll("{sessionId}", cliSessionIdToSend ?? ""), + ) + : baseArgs; const args = buildCliArgs({ backend, + baseArgs: resolvedArgs, modelId: normalizedModel, sessionId: cliSessionIdToSend, systemPrompt: systemPromptArg, imagePaths, promptArg: argsPrompt, + useResume, }); const serialize = backend.serialize ?? true; @@ -556,9 +633,16 @@ export async function runCliAgent(params: { }); } - if (backend.output === "text") { + const outputMode = + useResume ? backend.resumeOutput ?? backend.output : backend.output; + + if (outputMode === "text") { return { text: stdout, sessionId: undefined }; } + if (outputMode === "jsonl") { + const parsed = parseCliJsonl(stdout, backend); + return parsed ?? { text: stdout }; + } const parsed = parseCliJson(stdout, backend); return parsed ?? { text: stdout }; @@ -572,7 +656,7 @@ export async function runCliAgent(params: { meta: { durationMs: Date.now() - started, agentMeta: { - sessionId: output.sessionId ?? sessionIdSent ?? params.sessionId, + sessionId: output.sessionId ?? sessionIdSent, provider: params.provider, model: modelId, usage: output.usage, diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 408326e03..101804ec6 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -31,6 +31,7 @@ export function normalizeProviderId(provider: string): string { export function isCliProvider(provider: string, cfg?: ClawdbotConfig): boolean { const normalized = normalizeProviderId(provider); if (normalized === "claude-cli") return true; + if (normalized === "codex-cli") return true; const backends = cfg?.agents?.defaults?.cliBackends ?? {}; return Object.keys(backends).some( (key) => normalizeProviderId(key) === normalized, diff --git a/src/config/types.ts b/src/config/types.ts index b64101939..545566495 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1365,7 +1365,9 @@ export type CliBackendConfig = { /** Base args applied to every invocation. */ args?: string[]; /** Output parsing mode (default: json). */ - output?: "json" | "text"; + output?: "json" | "text" | "jsonl"; + /** Output parsing mode when resuming a CLI session. */ + resumeOutput?: "json" | "text" | "jsonl"; /** Prompt input mode (default: arg). */ input?: "arg" | "stdin"; /** Max prompt length for arg mode (if exceeded, stdin is used). */ @@ -1380,6 +1382,10 @@ export type CliBackendConfig = { modelAliases?: Record; /** Flag used to pass session id (e.g. --session-id). */ sessionArg?: string; + /** Extra args used when resuming a session (use {sessionId} placeholder). */ + sessionArgs?: string[]; + /** Alternate args to use when resuming a session (use {sessionId} placeholder). */ + resumeArgs?: string[]; /** When to pass session ids. */ sessionMode?: "always" | "existing" | "none"; /** JSON fields to read session id from (in order). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8cec1c4de..0d38a8c64 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -127,7 +127,12 @@ const HumanDelaySchema = z.object({ const CliBackendSchema = z.object({ command: z.string(), args: z.array(z.string()).optional(), - output: z.union([z.literal("json"), z.literal("text")]).optional(), + output: z + .union([z.literal("json"), z.literal("text"), z.literal("jsonl")]) + .optional(), + resumeOutput: z + .union([z.literal("json"), z.literal("text"), z.literal("jsonl")]) + .optional(), input: z.union([z.literal("arg"), z.literal("stdin")]).optional(), maxPromptArgChars: z.number().int().positive().optional(), env: z.record(z.string(), z.string()).optional(), @@ -135,6 +140,8 @@ const CliBackendSchema = z.object({ modelArg: z.string().optional(), modelAliases: z.record(z.string(), z.string()).optional(), sessionArg: z.string().optional(), + sessionArgs: z.array(z.string()).optional(), + resumeArgs: z.array(z.string()).optional(), sessionMode: z .union([z.literal("always"), z.literal("existing"), z.literal("none")]) .optional(), diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index c6657b78b..a0d3dce82 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -14,15 +14,25 @@ import { startGatewayServer } from "./server.js"; const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1"; const CLI_LIVE = process.env.CLAWDBOT_LIVE_CLI_BACKEND === "1"; const CLI_IMAGE = process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE === "1"; +const CLI_RESUME = process.env.CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE === "1"; const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5"; -const DEFAULT_ARGS = [ +const DEFAULT_CLAUDE_ARGS = [ "-p", "--output-format", "json", "--dangerously-skip-permissions", ]; +const DEFAULT_CODEX_ARGS = [ + "exec", + "--json", + "--color", + "never", + "--sandbox", + "read-only", + "--skip-git-repo-check", +]; const DEFAULT_CLEAR_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"]; function randomImageProbeCode(len = 10): string { @@ -213,25 +223,44 @@ describeLive("gateway live (cli backend)", () => { const rawModel = process.env.CLAWDBOT_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL; const parsed = parseModelRef(rawModel, "claude-cli"); - if (!parsed || parsed.provider !== "claude-cli") { + if (!parsed) { throw new Error( - `CLAWDBOT_LIVE_CLI_BACKEND_MODEL must resolve to a claude-cli model. Got: ${rawModel}`, + `CLAWDBOT_LIVE_CLI_BACKEND_MODEL must resolve to a CLI backend model. Got: ${rawModel}`, ); } - const modelKey = `${parsed.provider}/${parsed.model}`; + const providerId = parsed.provider; + const modelKey = `${providerId}/${parsed.model}`; + + const providerDefaults = + providerId === "claude-cli" + ? { command: "claude", args: DEFAULT_CLAUDE_ARGS } + : providerId === "codex-cli" + ? { command: "codex", args: DEFAULT_CODEX_ARGS } + : null; const cliCommand = - process.env.CLAWDBOT_LIVE_CLI_BACKEND_COMMAND ?? "claude"; + process.env.CLAWDBOT_LIVE_CLI_BACKEND_COMMAND ?? + providerDefaults?.command; + if (!cliCommand) { + throw new Error( + `CLAWDBOT_LIVE_CLI_BACKEND_COMMAND is required for provider "${providerId}".`, + ); + } const baseCliArgs = parseJsonStringArray( "CLAWDBOT_LIVE_CLI_BACKEND_ARGS", process.env.CLAWDBOT_LIVE_CLI_BACKEND_ARGS, - ) ?? DEFAULT_ARGS; + ) ?? providerDefaults?.args; + if (!baseCliArgs || baseCliArgs.length === 0) { + throw new Error( + `CLAWDBOT_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`, + ); + } const cliClearEnv = parseJsonStringArray( "CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV", process.env.CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV, - ) ?? DEFAULT_CLEAR_ENV; + ) ?? (providerId === "claude-cli" ? DEFAULT_CLEAR_ENV : []); const cliImageArg = process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG?.trim() || undefined; const cliImageMode = parseImageMode( @@ -247,16 +276,17 @@ describeLive("gateway live (cli backend)", () => { const tempDir = await fs.mkdtemp( path.join(os.tmpdir(), "clawdbot-live-cli-"), ); - const mcpConfigPath = path.join(tempDir, "claude-mcp.json"); - await fs.writeFile( - mcpConfigPath, - `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`, - ); const disableMcpConfig = process.env.CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0"; - const cliArgs = disableMcpConfig - ? withMcpConfigOverrides(baseCliArgs, mcpConfigPath) - : baseCliArgs; + let cliArgs = baseCliArgs; + if (providerId === "claude-cli" && disableMcpConfig) { + const mcpConfigPath = path.join(tempDir, "claude-mcp.json"); + await fs.writeFile( + mcpConfigPath, + `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`, + ); + cliArgs = withMcpConfigOverrides(baseCliArgs, mcpConfigPath); + } const cfg = loadConfig(); const existingBackends = cfg.agents?.defaults?.cliBackends ?? {}; @@ -272,10 +302,10 @@ describeLive("gateway live (cli backend)", () => { }, cliBackends: { ...existingBackends, - "claude-cli": { + [providerId]: { command: cliCommand, args: cliArgs, - clearEnv: cliClearEnv, + clearEnv: cliClearEnv.length > 0 ? cliClearEnv : undefined, systemPromptWhen: "never", ...(cliImageArg ? { imageArg: cliImageArg, imageMode: cliImageMode } @@ -306,12 +336,16 @@ describeLive("gateway live (cli backend)", () => { const sessionKey = "agent:dev:live-cli-backend"; const runId = randomUUID(); const nonce = randomBytes(3).toString("hex").toUpperCase(); + const message = + providerId === "codex-cli" + ? `Please include the token CLI-BACKEND-${nonce} in your reply.` + : `Reply with exactly: CLI backend OK ${nonce}.`; const payload = await client.request>( "agent", { sessionKey, idempotencyKey: `idem-${runId}`, - message: `Reply with exactly: CLI backend OK ${nonce}.`, + message, deliver: false, }, { expectFinal: true }, @@ -320,7 +354,43 @@ describeLive("gateway live (cli backend)", () => { throw new Error(`agent status=${String(payload?.status)}`); } const text = extractPayloadText(payload?.result); - expect(text).toContain(`CLI backend OK ${nonce}.`); + if (providerId === "codex-cli") { + expect(text).toContain(`CLI-BACKEND-${nonce}`); + } else { + expect(text).toContain(`CLI backend OK ${nonce}.`); + } + + if (CLI_RESUME) { + const runIdResume = randomUUID(); + const resumeNonce = randomBytes(3).toString("hex").toUpperCase(); + const resumeMessage = + providerId === "codex-cli" + ? `Please include the token CLI-RESUME-${resumeNonce} in your reply.` + : `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`; + const resumePayload = await client.request>( + "agent", + { + sessionKey, + idempotencyKey: `idem-${runIdResume}`, + message: resumeMessage, + deliver: false, + }, + { expectFinal: true }, + ); + if (resumePayload?.status !== "ok") { + throw new Error( + `resume status=${String(resumePayload?.status)}`, + ); + } + const resumeText = extractPayloadText(resumePayload?.result); + if (providerId === "codex-cli") { + expect(resumeText).toContain(`CLI-RESUME-${resumeNonce}`); + } else { + expect(resumeText).toContain( + `CLI backend RESUME OK ${resumeNonce}.`, + ); + } + } if (CLI_IMAGE) { const imageCode = randomImageProbeCode(10);