feat: add codex cli backend
This commit is contained in:
@@ -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
|
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
|
If your gateway runs under launchd/systemd and PATH is minimal, add just the
|
||||||
command path:
|
command path:
|
||||||
|
|
||||||
@@ -133,7 +139,12 @@ The provider id becomes the left side of your model ref:
|
|||||||
|
|
||||||
## Sessions
|
## 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`:
|
- `sessionMode`:
|
||||||
- `always`: always send a session id (new UUID if none stored).
|
- `always`: always send a session id (new UUID if none stored).
|
||||||
- `existing`: only send a session id if one was stored before.
|
- `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
|
## Inputs / outputs
|
||||||
|
|
||||||
- `output: "json"` (default) tries to parse JSON and extract text + session id.
|
- `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.
|
- `output: "text"` treats stdout as the final response.
|
||||||
|
|
||||||
Input modes:
|
Input modes:
|
||||||
@@ -175,17 +188,33 @@ Clawdbot ships a default for `claude-cli`:
|
|||||||
- `systemPromptWhen: "first"`
|
- `systemPromptWhen: "first"`
|
||||||
- `sessionMode: "always"`
|
- `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).
|
Override only if needed (common: absolute `command` path).
|
||||||
|
|
||||||
## Limitations
|
## 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).
|
- **No streaming** (CLI output is collected then returned).
|
||||||
- **Structured outputs** depend on the CLI’s JSON format.
|
- **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
|
## Troubleshooting
|
||||||
|
|
||||||
- **CLI not found**: set `command` to a full path.
|
- **CLI not found**: set `command` to a full path.
|
||||||
- **Wrong model name**: use `modelAliases` to map `provider/model` → CLI model.
|
- **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).
|
- **Images ignored**: set `imageArg` (and verify CLI supports file paths).
|
||||||
|
|||||||
@@ -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"]`
|
- Args: `["-p","--output-format","json","--dangerously-skip-permissions"]`
|
||||||
- Overrides (optional):
|
- Overrides (optional):
|
||||||
- `CLAWDBOT_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-opus-4-5"`
|
- `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_COMMAND="/full/path/to/claude"`
|
||||||
- `CLAWDBOT_LIVE_CLI_BACKEND_ARGS='["-p","--output-format","json","--permission-mode","bypassPermissions"]'`
|
- `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_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_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_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_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).
|
- `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:
|
Example:
|
||||||
|
|||||||
@@ -47,6 +47,38 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
|
|||||||
serialize: true,
|
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 {
|
function normalizeBackendKey(key: string): string {
|
||||||
return normalizeProviderId(key);
|
return normalizeProviderId(key);
|
||||||
}
|
}
|
||||||
@@ -76,11 +108,16 @@ function mergeBackendConfig(
|
|||||||
new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]),
|
new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]),
|
||||||
),
|
),
|
||||||
sessionIdFields: override.sessionIdFields ?? base.sessionIdFields,
|
sessionIdFields: override.sessionIdFields ?? base.sessionIdFields,
|
||||||
|
sessionArgs: override.sessionArgs ?? base.sessionArgs,
|
||||||
|
resumeArgs: override.resumeArgs ?? base.resumeArgs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCliBackendIds(cfg?: ClawdbotConfig): Set<string> {
|
export function resolveCliBackendIds(cfg?: ClawdbotConfig): Set<string> {
|
||||||
const ids = new Set<string>([normalizeBackendKey("claude-cli")]);
|
const ids = new Set<string>([
|
||||||
|
normalizeBackendKey("claude-cli"),
|
||||||
|
normalizeBackendKey("codex-cli"),
|
||||||
|
]);
|
||||||
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
|
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
|
||||||
for (const key of Object.keys(configured)) {
|
for (const key of Object.keys(configured)) {
|
||||||
ids.add(normalizeBackendKey(key));
|
ids.add(normalizeBackendKey(key));
|
||||||
@@ -102,6 +139,12 @@ export function resolveCliBackendConfig(
|
|||||||
if (!command) return null;
|
if (!command) return null;
|
||||||
return { id: normalized, config: { ...merged, command } };
|
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;
|
if (!override) return null;
|
||||||
const command = override.command?.trim();
|
const command = override.command?.trim();
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ function toUsage(raw: Record<string, unknown>): CliUsage | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const input = pick("input_tokens") ?? pick("inputTokens");
|
const input = pick("input_tokens") ?? pick("inputTokens");
|
||||||
const output = pick("output_tokens") ?? pick("outputTokens");
|
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 cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite");
|
||||||
const total = pick("total_tokens") ?? pick("total");
|
const total = pick("total_tokens") ?? pick("total");
|
||||||
if (!input && !output && !cacheRead && !cacheWrite && !total)
|
if (!input && !output && !cacheRead && !cacheWrite && !total)
|
||||||
@@ -246,6 +249,47 @@ function parseCliJson(
|
|||||||
return { text: text.trim(), sessionId, usage };
|
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: {
|
function resolveSystemPromptUsage(params: {
|
||||||
backend: CliBackendConfig;
|
backend: CliBackendConfig;
|
||||||
isNewSession: boolean;
|
isNewSession: boolean;
|
||||||
@@ -328,21 +372,33 @@ async function writeCliImages(
|
|||||||
|
|
||||||
function buildCliArgs(params: {
|
function buildCliArgs(params: {
|
||||||
backend: CliBackendConfig;
|
backend: CliBackendConfig;
|
||||||
|
baseArgs: string[];
|
||||||
modelId: string;
|
modelId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
systemPrompt?: string | null;
|
systemPrompt?: string | null;
|
||||||
imagePaths?: string[];
|
imagePaths?: string[];
|
||||||
promptArg?: string;
|
promptArg?: string;
|
||||||
|
useResume: boolean;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const args: string[] = [...(params.backend.args ?? [])];
|
const args: string[] = [...params.baseArgs];
|
||||||
if (params.backend.modelArg && params.modelId) {
|
if (!params.useResume && params.backend.modelArg && params.modelId) {
|
||||||
args.push(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);
|
args.push(params.backend.systemPromptArg, params.systemPrompt);
|
||||||
}
|
}
|
||||||
if (params.sessionId && params.backend.sessionArg) {
|
if (!params.useResume && params.sessionId) {
|
||||||
args.push(params.backend.sessionArg, 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) {
|
if (params.imagePaths && params.imagePaths.length > 0) {
|
||||||
const mode = params.backend.imageMode ?? "repeat";
|
const mode = params.backend.imageMode ?? "repeat";
|
||||||
@@ -434,8 +490,19 @@ export async function runCliAgent(params: {
|
|||||||
backend,
|
backend,
|
||||||
cliSessionId: params.cliSessionId,
|
cliSessionId: params.cliSessionId,
|
||||||
});
|
});
|
||||||
const sessionIdSent =
|
const useResume = Boolean(
|
||||||
backend.sessionArg && cliSessionIdToSend ? cliSessionIdToSend : undefined;
|
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({
|
const systemPromptArg = resolveSystemPromptUsage({
|
||||||
backend,
|
backend,
|
||||||
isNewSession: isNew,
|
isNewSession: isNew,
|
||||||
@@ -459,13 +526,23 @@ export async function runCliAgent(params: {
|
|||||||
prompt,
|
prompt,
|
||||||
});
|
});
|
||||||
const stdinPayload = stdin ?? "";
|
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({
|
const args = buildCliArgs({
|
||||||
backend,
|
backend,
|
||||||
|
baseArgs: resolvedArgs,
|
||||||
modelId: normalizedModel,
|
modelId: normalizedModel,
|
||||||
sessionId: cliSessionIdToSend,
|
sessionId: cliSessionIdToSend,
|
||||||
systemPrompt: systemPromptArg,
|
systemPrompt: systemPromptArg,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
promptArg: argsPrompt,
|
promptArg: argsPrompt,
|
||||||
|
useResume,
|
||||||
});
|
});
|
||||||
|
|
||||||
const serialize = backend.serialize ?? true;
|
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 };
|
return { text: stdout, sessionId: undefined };
|
||||||
}
|
}
|
||||||
|
if (outputMode === "jsonl") {
|
||||||
|
const parsed = parseCliJsonl(stdout, backend);
|
||||||
|
return parsed ?? { text: stdout };
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = parseCliJson(stdout, backend);
|
const parsed = parseCliJson(stdout, backend);
|
||||||
return parsed ?? { text: stdout };
|
return parsed ?? { text: stdout };
|
||||||
@@ -572,7 +656,7 @@ export async function runCliAgent(params: {
|
|||||||
meta: {
|
meta: {
|
||||||
durationMs: Date.now() - started,
|
durationMs: Date.now() - started,
|
||||||
agentMeta: {
|
agentMeta: {
|
||||||
sessionId: output.sessionId ?? sessionIdSent ?? params.sessionId,
|
sessionId: output.sessionId ?? sessionIdSent,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
model: modelId,
|
model: modelId,
|
||||||
usage: output.usage,
|
usage: output.usage,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function normalizeProviderId(provider: string): string {
|
|||||||
export function isCliProvider(provider: string, cfg?: ClawdbotConfig): boolean {
|
export function isCliProvider(provider: string, cfg?: ClawdbotConfig): boolean {
|
||||||
const normalized = normalizeProviderId(provider);
|
const normalized = normalizeProviderId(provider);
|
||||||
if (normalized === "claude-cli") return true;
|
if (normalized === "claude-cli") return true;
|
||||||
|
if (normalized === "codex-cli") return true;
|
||||||
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
|
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
|
||||||
return Object.keys(backends).some(
|
return Object.keys(backends).some(
|
||||||
(key) => normalizeProviderId(key) === normalized,
|
(key) => normalizeProviderId(key) === normalized,
|
||||||
|
|||||||
@@ -1365,7 +1365,9 @@ export type CliBackendConfig = {
|
|||||||
/** Base args applied to every invocation. */
|
/** Base args applied to every invocation. */
|
||||||
args?: string[];
|
args?: string[];
|
||||||
/** Output parsing mode (default: json). */
|
/** 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). */
|
/** Prompt input mode (default: arg). */
|
||||||
input?: "arg" | "stdin";
|
input?: "arg" | "stdin";
|
||||||
/** Max prompt length for arg mode (if exceeded, stdin is used). */
|
/** Max prompt length for arg mode (if exceeded, stdin is used). */
|
||||||
@@ -1380,6 +1382,10 @@ export type CliBackendConfig = {
|
|||||||
modelAliases?: Record<string, string>;
|
modelAliases?: Record<string, string>;
|
||||||
/** Flag used to pass session id (e.g. --session-id). */
|
/** Flag used to pass session id (e.g. --session-id). */
|
||||||
sessionArg?: string;
|
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. */
|
/** When to pass session ids. */
|
||||||
sessionMode?: "always" | "existing" | "none";
|
sessionMode?: "always" | "existing" | "none";
|
||||||
/** JSON fields to read session id from (in order). */
|
/** JSON fields to read session id from (in order). */
|
||||||
|
|||||||
@@ -127,7 +127,12 @@ const HumanDelaySchema = z.object({
|
|||||||
const CliBackendSchema = z.object({
|
const CliBackendSchema = z.object({
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
args: z.array(z.string()).optional(),
|
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(),
|
input: z.union([z.literal("arg"), z.literal("stdin")]).optional(),
|
||||||
maxPromptArgChars: z.number().int().positive().optional(),
|
maxPromptArgChars: z.number().int().positive().optional(),
|
||||||
env: z.record(z.string(), z.string()).optional(),
|
env: z.record(z.string(), z.string()).optional(),
|
||||||
@@ -135,6 +140,8 @@ const CliBackendSchema = z.object({
|
|||||||
modelArg: z.string().optional(),
|
modelArg: z.string().optional(),
|
||||||
modelAliases: z.record(z.string(), z.string()).optional(),
|
modelAliases: z.record(z.string(), z.string()).optional(),
|
||||||
sessionArg: z.string().optional(),
|
sessionArg: z.string().optional(),
|
||||||
|
sessionArgs: z.array(z.string()).optional(),
|
||||||
|
resumeArgs: z.array(z.string()).optional(),
|
||||||
sessionMode: z
|
sessionMode: z
|
||||||
.union([z.literal("always"), z.literal("existing"), z.literal("none")])
|
.union([z.literal("always"), z.literal("existing"), z.literal("none")])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -14,15 +14,25 @@ import { startGatewayServer } from "./server.js";
|
|||||||
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
|
||||||
const CLI_LIVE = process.env.CLAWDBOT_LIVE_CLI_BACKEND === "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_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 describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
|
||||||
|
|
||||||
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5";
|
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5";
|
||||||
const DEFAULT_ARGS = [
|
const DEFAULT_CLAUDE_ARGS = [
|
||||||
"-p",
|
"-p",
|
||||||
"--output-format",
|
"--output-format",
|
||||||
"json",
|
"json",
|
||||||
"--dangerously-skip-permissions",
|
"--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"];
|
const DEFAULT_CLEAR_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"];
|
||||||
|
|
||||||
function randomImageProbeCode(len = 10): string {
|
function randomImageProbeCode(len = 10): string {
|
||||||
@@ -213,25 +223,44 @@ describeLive("gateway live (cli backend)", () => {
|
|||||||
const rawModel =
|
const rawModel =
|
||||||
process.env.CLAWDBOT_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
|
process.env.CLAWDBOT_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
|
||||||
const parsed = parseModelRef(rawModel, "claude-cli");
|
const parsed = parseModelRef(rawModel, "claude-cli");
|
||||||
if (!parsed || parsed.provider !== "claude-cli") {
|
if (!parsed) {
|
||||||
throw new Error(
|
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 =
|
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 =
|
const baseCliArgs =
|
||||||
parseJsonStringArray(
|
parseJsonStringArray(
|
||||||
"CLAWDBOT_LIVE_CLI_BACKEND_ARGS",
|
"CLAWDBOT_LIVE_CLI_BACKEND_ARGS",
|
||||||
process.env.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 =
|
const cliClearEnv =
|
||||||
parseJsonStringArray(
|
parseJsonStringArray(
|
||||||
"CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV",
|
"CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV",
|
||||||
process.env.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 =
|
const cliImageArg =
|
||||||
process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG?.trim() || undefined;
|
process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG?.trim() || undefined;
|
||||||
const cliImageMode = parseImageMode(
|
const cliImageMode = parseImageMode(
|
||||||
@@ -247,16 +276,17 @@ describeLive("gateway live (cli backend)", () => {
|
|||||||
const tempDir = await fs.mkdtemp(
|
const tempDir = await fs.mkdtemp(
|
||||||
path.join(os.tmpdir(), "clawdbot-live-cli-"),
|
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 =
|
const disableMcpConfig =
|
||||||
process.env.CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0";
|
process.env.CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0";
|
||||||
const cliArgs = disableMcpConfig
|
let cliArgs = baseCliArgs;
|
||||||
? withMcpConfigOverrides(baseCliArgs, mcpConfigPath)
|
if (providerId === "claude-cli" && disableMcpConfig) {
|
||||||
: baseCliArgs;
|
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 cfg = loadConfig();
|
||||||
const existingBackends = cfg.agents?.defaults?.cliBackends ?? {};
|
const existingBackends = cfg.agents?.defaults?.cliBackends ?? {};
|
||||||
@@ -272,10 +302,10 @@ describeLive("gateway live (cli backend)", () => {
|
|||||||
},
|
},
|
||||||
cliBackends: {
|
cliBackends: {
|
||||||
...existingBackends,
|
...existingBackends,
|
||||||
"claude-cli": {
|
[providerId]: {
|
||||||
command: cliCommand,
|
command: cliCommand,
|
||||||
args: cliArgs,
|
args: cliArgs,
|
||||||
clearEnv: cliClearEnv,
|
clearEnv: cliClearEnv.length > 0 ? cliClearEnv : undefined,
|
||||||
systemPromptWhen: "never",
|
systemPromptWhen: "never",
|
||||||
...(cliImageArg
|
...(cliImageArg
|
||||||
? { imageArg: cliImageArg, imageMode: cliImageMode }
|
? { imageArg: cliImageArg, imageMode: cliImageMode }
|
||||||
@@ -306,12 +336,16 @@ describeLive("gateway live (cli backend)", () => {
|
|||||||
const sessionKey = "agent:dev:live-cli-backend";
|
const sessionKey = "agent:dev:live-cli-backend";
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
const nonce = randomBytes(3).toString("hex").toUpperCase();
|
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<Record<string, unknown>>(
|
const payload = await client.request<Record<string, unknown>>(
|
||||||
"agent",
|
"agent",
|
||||||
{
|
{
|
||||||
sessionKey,
|
sessionKey,
|
||||||
idempotencyKey: `idem-${runId}`,
|
idempotencyKey: `idem-${runId}`,
|
||||||
message: `Reply with exactly: CLI backend OK ${nonce}.`,
|
message,
|
||||||
deliver: false,
|
deliver: false,
|
||||||
},
|
},
|
||||||
{ expectFinal: true },
|
{ expectFinal: true },
|
||||||
@@ -320,7 +354,43 @@ describeLive("gateway live (cli backend)", () => {
|
|||||||
throw new Error(`agent status=${String(payload?.status)}`);
|
throw new Error(`agent status=${String(payload?.status)}`);
|
||||||
}
|
}
|
||||||
const text = extractPayloadText(payload?.result);
|
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<Record<string, unknown>>(
|
||||||
|
"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) {
|
if (CLI_IMAGE) {
|
||||||
const imageCode = randomImageProbeCode(10);
|
const imageCode = randomImageProbeCode(10);
|
||||||
|
|||||||
Reference in New Issue
Block a user