use std::ffi::OsString;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command as TokioCommand;
use tokio::time::timeout;
use super::binary_finder::{command_name, find_binary};
use super::types::{AgentKind, GateResult};
pub(super) async fn run(agent: AgentKind, prompt: &str, time_budget: Duration) -> GateResult {
if command_name(agent).is_none() {
return GateResult::errored_with(format!(
"{} has no headless CLI; configure a different agent or BYOK provider",
agent.label(),
));
}
let Some(binary) = find_binary(agent) else {
return GateResult::errored_with(format!(
"could not locate {} on PATH or in any known install location",
agent.label(),
));
};
let args = build_args(agent, prompt);
let via_stdin = prompt_via_stdin(agent);
let mut command = TokioCommand::new(&binary);
command.args(&args);
command.kill_on_drop(true);
command.env(difflore_core::cloud::capture::DIFFLORE_CAPTURE_ENV, "false");
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
command.stdin(if via_stdin {
Stdio::piped()
} else {
Stdio::null()
});
let stdin_prompt = via_stdin.then_some(prompt);
let spawn_result = match timeout(time_budget, spawn_and_capture(command, stdin_prompt)).await
{
Ok(Ok(output)) => output,
Ok(Err(e)) => {
return GateResult::errored_with(format!(
"failed to spawn {}: {e}",
binary.display(),
));
}
Err(_) => {
return GateResult::errored_with(format!(
"{} timed out after {}s",
agent.label(),
time_budget.as_secs(),
));
}
};
let stdout = String::from_utf8_lossy(&spawn_result.stdout).trim().to_owned();
let stderr = String::from_utf8_lossy(&spawn_result.stderr).trim().to_owned();
if spawn_result.status.success() {
return GateResult {
stdout,
stderr,
errored: false,
error_message: String::new(),
};
}
let code = spawn_result
.status
.code()
.map_or_else(|| "no exit code".to_owned(), |c| format!("exit {c}"));
GateResult {
stdout,
stderr,
errored: true,
error_message: format!("{} failed ({code})", agent.label()),
}
}
fn prompt_via_stdin(agent: AgentKind) -> bool {
matches!(agent, AgentKind::ClaudeCode | AgentKind::Codex)
}
async fn spawn_and_capture(
mut command: TokioCommand,
stdin_prompt: Option<&str>,
) -> std::io::Result<std::process::Output> {
use tokio::io::AsyncWriteExt;
let mut child = command.spawn()?;
if let Some(prompt) = stdin_prompt
&& let Some(mut stdin) = child.stdin.take()
{
let _ = stdin.write_all(prompt.as_bytes()).await;
let _ = stdin.shutdown().await;
}
child.wait_with_output().await
}
pub(super) fn build_args(agent: AgentKind, prompt: &str) -> Vec<OsString> {
let mut args: Vec<OsString> = Vec::new();
match agent {
AgentKind::ClaudeCode => {
args.push(OsString::from("-p"));
args.push(OsString::from("--no-session-persistence"));
args.push(OsString::from("--model"));
args.push(OsString::from("haiku"));
args.push(OsString::from("--permission-mode"));
args.push(OsString::from("bypassPermissions"));
}
AgentKind::Codex => {
args.push(OsString::from("exec"));
args.push(OsString::from("--dangerously-bypass-approvals-and-sandbox"));
}
AgentKind::Cursor => {
args.push(OsString::from("--print"));
args.push(OsString::from("--model"));
args.push(OsString::from("auto"));
args.push(OsString::from("--force"));
args.push(OsString::from("--output-format"));
args.push(OsString::from("text"));
args.push(OsString::from(prompt));
}
AgentKind::GeminiCli => {
args.push(OsString::from("-p"));
args.push(OsString::from(prompt));
}
AgentKind::Windsurf => {
}
}
args
}
#[cfg(test)]
pub(super) fn args_as_strings(args: &[OsString]) -> Vec<String> {
args.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_args_use_haiku_no_session_bypass() {
let args = args_as_strings(&build_args(AgentKind::ClaudeCode, "hello"));
assert_eq!(
args,
vec![
"-p",
"--no-session-persistence",
"--model",
"haiku",
"--permission-mode",
"bypassPermissions",
]
);
assert!(!args.contains(&"hello".to_owned()));
}
#[test]
fn codex_args_use_exec_with_bypass() {
let args = args_as_strings(&build_args(AgentKind::Codex, "hi"));
assert_eq!(
args,
vec!["exec", "--dangerously-bypass-approvals-and-sandbox"]
);
assert!(!args.contains(&"hi".to_owned()));
}
#[test]
fn cursor_args_force_text_output_with_auto_model() {
let args = args_as_strings(&build_args(AgentKind::Cursor, "go"));
assert_eq!(
args,
vec![
"--print",
"--model",
"auto",
"--force",
"--output-format",
"text",
"go",
]
);
}
#[test]
fn gemini_args_use_print_flag() {
let args = args_as_strings(&build_args(AgentKind::GeminiCli, "ok"));
assert_eq!(args, vec!["-p", "ok"]);
}
#[test]
fn windsurf_args_empty_because_unreachable_path() {
let args = args_as_strings(&build_args(AgentKind::Windsurf, "ignored"));
assert!(args.is_empty());
}
#[test]
fn prompt_is_last_arg_for_argv_delivered_agents() {
for (agent, prompt) in [
(AgentKind::Cursor, "cursor-prompt"),
(AgentKind::GeminiCli, "gemini-prompt"),
] {
let args = args_as_strings(&build_args(agent, prompt));
assert_eq!(args.last().map(String::as_str), Some(prompt), "agent {agent:?}");
}
}
#[test]
fn build_args_matches_prompt_via_stdin() {
let secret = "PROMPT-SENTINEL-1234";
for agent in [
AgentKind::ClaudeCode,
AgentKind::Codex,
AgentKind::Cursor,
AgentKind::GeminiCli,
] {
let args = args_as_strings(&build_args(agent, secret));
let in_argv = args.iter().any(|a| a.contains(secret));
assert_eq!(
in_argv,
!prompt_via_stdin(agent),
"agent {agent:?}: prompt-in-argv should equal !prompt_via_stdin",
);
}
}
#[tokio::test]
async fn windsurf_returns_errored_without_spawning() {
let result = run(AgentKind::Windsurf, "ignored", Duration::from_millis(10)).await;
assert!(result.errored);
assert!(result.error_message.contains("no headless CLI"));
assert!(result.stdout.is_empty());
}
}