Improve CLI backend reliability and error diagnostics

- Use `cat file | command` instead of `command < file` for stdin piping
  to avoid redirect issues in nested shell wrappers
- Include both stdout and stderr in error messages when CLI exits non-zero
  (previously only showed stderr, hiding API errors in stdout)
- Install Node 20 via nodesource + bash/curl as prerequisites for CLI tests
- Use bash instead of sh for Claude CLI install script

The codex test still fails with 401 Unauthorized — the OPENAI_API_KEY
env var isn't reaching the codex process in the sandbox despite the env
file being sourced correctly (gemini works with the same mechanism).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-08 19:06:03 -04:00
parent 59c01d62fd
commit dd9bed01e5

View file

@ -161,7 +161,7 @@ pub fn is_cli_only_model(model: &str) -> bool {
/// Build the CLI command string for a given provider.
///
/// The `prompt_file` is the path to a file containing the prompt text, which
/// will be shell-redirected into the command's stdin.
/// is piped into the command's stdin via `cat`.
#[must_use]
pub fn cli_command_for_provider(provider: Provider, model: &str, prompt_file: &str) -> String {
let model_flag = if model.is_empty() {
@ -179,16 +179,19 @@ pub fn cli_command_for_provider(provider: Provider, model: &str, prompt_file: &s
Provider::Anthropic => format!(" --model {model}"),
}
};
// Use `cat | command` instead of `command < file` because the background
// launch wrapper (`setsid sh -c '...' </dev/null`) can clobber stdin
// redirects in nested shells. A pipe creates an explicit new stdin.
match provider {
// --full-auto: sandboxed auto-execution, escalates on request
Provider::OpenAi | Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => {
format!("codex exec --json --full-auto{model_flag} < {prompt_file}")
format!("cat {prompt_file} | codex exec --json --full-auto{model_flag}")
}
// --yolo: auto-approve all tool calls
Provider::Gemini => format!("gemini -o json --yolo{model_flag} < {prompt_file}"),
Provider::Gemini => format!("cat {prompt_file} | gemini -o json --yolo{model_flag}"),
// --dangerously-skip-permissions: bypass all permission checks (required for non-interactive use).
// CLAUDECODE= unset to allow running inside a Claude Code session.
Provider::Anthropic => format!("CLAUDECODE= claude -p --verbose --output-format stream-json --dangerously-skip-permissions{model_flag} < {prompt_file}"),
Provider::Anthropic => format!("cat {prompt_file} | CLAUDECODE= claude -p --verbose --output-format stream-json --dangerously-skip-permissions{model_flag}"),
}
}
@ -598,32 +601,22 @@ impl CodergenBackend for AgentCliBackend {
let _ = tokio::fs::write(stage_dir.join("cli_stdout.log"), &result.stdout).await;
let _ = tokio::fs::write(stage_dir.join("cli_stderr.log"), &result.stderr).await;
let stderr: String = result
.stderr
.chars()
.rev()
.take(500)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let detail = if !stderr.is_empty() {
stderr
} else {
let stdout: String = result
.stdout
.chars()
let tail = |s: &str, n: usize| -> String {
s.chars()
.rev()
.take(500)
.take(n)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if !stdout.is_empty() {
format!("stdout: {stdout}")
} else {
format!("command: {command}")
}
.collect()
};
let stderr_tail = tail(&result.stderr, 500);
let stdout_tail = tail(&result.stdout, 500);
let detail = match (stderr_tail.is_empty(), stdout_tail.is_empty()) {
(false, false) => format!("{stderr_tail}\nstdout: {stdout_tail}"),
(false, true) => stderr_tail,
(true, false) => format!("stdout: {stdout_tail}"),
(true, true) => format!("command: {command}"),
};
return Err(ArcError::handler(format!(
"CLI command exited with code {}: {detail}",
@ -944,15 +937,15 @@ mod tests {
#[test]
fn cli_command_for_codex() {
let cmd = cli_command_for_provider(Provider::OpenAi, "gpt-5.3-codex", "/tmp/prompt.txt");
assert!(cmd.starts_with("codex exec --json --full-auto"));
assert!(cmd.starts_with("cat /tmp/prompt.txt | codex exec --json --full-auto"));
assert!(cmd.contains("-m gpt-5.3-codex"));
assert!(cmd.ends_with("< /tmp/prompt.txt"));
}
#[test]
fn cli_command_for_claude() {
let cmd =
cli_command_for_provider(Provider::Anthropic, "claude-opus-4-6", "/tmp/prompt.txt");
assert!(cmd.starts_with("cat /tmp/prompt.txt |"));
assert!(cmd.contains("claude -p"));
assert!(cmd.contains("--dangerously-skip-permissions"));
assert!(cmd.contains("--output-format stream-json"));
@ -962,14 +955,14 @@ mod tests {
#[test]
fn cli_command_for_gemini() {
let cmd = cli_command_for_provider(Provider::Gemini, "gemini-3.1-pro", "/tmp/prompt.txt");
assert!(cmd.starts_with("gemini -o json --yolo"));
assert!(cmd.starts_with("cat /tmp/prompt.txt | gemini -o json --yolo"));
assert!(cmd.contains("-m gemini-3.1-pro"));
}
#[test]
fn cli_command_omits_model_when_empty() {
let cmd = cli_command_for_provider(Provider::OpenAi, "", "/tmp/prompt.txt");
assert!(cmd.starts_with("codex exec --json --full-auto"));
assert!(cmd.contains("codex exec --json --full-auto"));
assert!(!cmd.contains("-m "));
let cmd = cli_command_for_provider(Provider::Anthropic, "", "/tmp/prompt.txt");
assert!(cmd.contains("--dangerously-skip-permissions"));