use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::Stdio;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use tracing::{debug, info, warn};
use super::{AiClient, AiClientCapabilities, AiClientMetadata, RequestOptions};
use crate::claude::error::ClaudeError;
pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(600);
pub(crate) const DEFAULT_STDOUT_CAP: usize = 4 * 1024 * 1024;
pub(crate) const DEFAULT_BINARY: &str = "claude";
pub(crate) const TIMEOUT_ENV_VAR: &str = "OMNI_DEV_CLAUDE_CLI_TIMEOUT_SECS";
pub(crate) const STDOUT_CAP_ENV_VAR: &str = "OMNI_DEV_CLAUDE_CLI_STDOUT_MAX_BYTES";
pub(crate) const BINARY_ENV_VAR: &str = "OMNI_DEV_CLAUDE_CLI_BIN";
pub(crate) const ALLOW_TOOLS_ENV_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS";
pub(crate) const ALLOW_MCP_ENV_VAR: &str = "OMNI_DEV_CLAUDE_CLI_ALLOW_MCP";
pub(crate) const MAX_BUDGET_ENV_VAR: &str = "OMNI_DEV_CLAUDE_CLI_MAX_BUDGET_USD";
pub(crate) const TOOL_SUPPRESSION_SUFFIX: &str =
"\n\nYou have no tools available in this session. \
Do not emit function_calls XML or attempt any tool invocation. Output only the requested content.";
const SCRUBBED_ENV_PREFIXES: &[&str] = &["CLAUDE_CODE_", "CLAUDE_PROJECT_"];
const SCRUBBED_ENV_EXACT: &[&str] = &["CLAUDE_PROJECT_DIR"];
#[derive(Deserialize)]
struct JsonOutput {
#[serde(default)]
is_error: bool,
#[serde(default)]
api_error_status: Option<i64>,
#[serde(default)]
result: String,
#[serde(default)]
total_cost_usd: Option<f64>,
}
pub struct ClaudeCliAiClient {
model: String,
timeout: Duration,
stdout_cap: usize,
allow_tools: bool,
allow_mcp: bool,
binary_path: PathBuf,
max_budget_usd: Option<f64>,
}
impl ClaudeCliAiClient {
#[must_use]
pub fn new(model: String) -> Self {
Self::new_with_config(
model,
Self::timeout_from_env(),
Self::stdout_cap_from_env(),
Self::allow_tools_from_env(),
Self::binary_from_env(),
)
.with_allow_mcp(Self::allow_mcp_from_env())
.with_max_budget_usd(Self::max_budget_from_env())
}
#[must_use]
pub fn new_with_config(
model: String,
timeout: Duration,
stdout_cap: usize,
allow_tools: bool,
binary_path: PathBuf,
) -> Self {
Self {
model,
timeout,
stdout_cap,
allow_tools,
allow_mcp: false,
binary_path,
max_budget_usd: None,
}
}
#[must_use]
pub fn with_allow_mcp(mut self, allow_mcp: bool) -> Self {
self.allow_mcp = allow_mcp;
self
}
#[must_use]
pub fn with_max_budget_usd(mut self, budget: Option<f64>) -> Self {
self.max_budget_usd = budget;
self
}
fn timeout_from_env() -> Duration {
crate::utils::settings::get_env_var(TIMEOUT_ENV_VAR)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map_or(DEFAULT_TIMEOUT, Duration::from_secs)
}
fn stdout_cap_from_env() -> usize {
crate::utils::settings::get_env_var(STDOUT_CAP_ENV_VAR)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(DEFAULT_STDOUT_CAP)
}
fn binary_from_env() -> PathBuf {
crate::utils::settings::get_env_var(BINARY_ENV_VAR)
.ok()
.map_or_else(|| PathBuf::from(DEFAULT_BINARY), PathBuf::from)
}
fn allow_tools_from_env() -> bool {
crate::utils::settings::get_env_var(ALLOW_TOOLS_ENV_VAR)
.ok()
.is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes"))
}
fn allow_mcp_from_env() -> bool {
crate::utils::settings::get_env_var(ALLOW_MCP_ENV_VAR)
.ok()
.is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes"))
}
fn max_budget_from_env() -> Option<f64> {
crate::utils::settings::get_env_var(MAX_BUDGET_ENV_VAR)
.ok()
.and_then(|v| v.trim().parse::<f64>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
}
#[cfg(test)]
pub(crate) fn build_command(&self, system_prompt: &str, cwd: &Path) -> Command {
self.build_command_with_schema(system_prompt, cwd, None)
}
pub(crate) fn build_command_with_schema(
&self,
system_prompt: &str,
cwd: &Path,
schema_path: Option<&Path>,
) -> Command {
let mut cmd = Command::new(&self.binary_path);
cmd.arg("-p")
.arg("--model")
.arg(&self.model)
.arg("--output-format")
.arg("json")
.arg("--permission-mode")
.arg("default")
.arg("--no-session-persistence")
.arg("--disable-slash-commands")
.arg("--setting-sources")
.arg("")
.arg("--system-prompt")
.arg(system_prompt);
if !self.allow_tools {
cmd.arg("--tools").arg("");
}
if !self.allow_mcp {
cmd.arg("--strict-mcp-config");
}
if let Some(budget) = self.max_budget_usd {
cmd.arg("--max-budget-usd").arg(format!("{budget}"));
}
if let Some(path) = schema_path {
cmd.arg("--json-schema").arg(path);
}
cmd.current_dir(cwd);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
cmd.process_group(0);
for (k, _) in std::env::vars() {
if SCRUBBED_ENV_EXACT.contains(&k.as_str())
|| SCRUBBED_ENV_PREFIXES.iter().any(|p| k.starts_with(p))
{
cmd.env_remove(&k);
}
}
cmd
}
async fn run(&self, system_prompt: &str, user_prompt: &str) -> Result<String> {
self.run_with_options(system_prompt, user_prompt, &RequestOptions::default())
.await
}
async fn run_with_options(
&self,
system_prompt: &str,
user_prompt: &str,
options: &RequestOptions,
) -> Result<String> {
let combined_system = format!("{system_prompt}{TOOL_SUPPRESSION_SUFFIX}");
let temp_dir = tempfile::TempDir::new()
.context("Failed to create temp directory for claude subprocess")?;
let schema_path = if let Some(schema) = &options.response_schema {
let path = temp_dir.path().join("response_schema.json");
let body = serde_json::to_vec(schema)
.context("Failed to serialize response schema for claude -p --json-schema")?;
tokio::fs::write(&path, body)
.await
.context("Failed to write response schema file for claude -p")?;
Some(path)
} else {
None
};
let mut cmd = self.build_command_with_schema(
&combined_system,
temp_dir.path(),
schema_path.as_deref(),
);
if self.allow_tools {
warn!(
"claude -p sandbox weakened: tool-access escape hatch is enabled \
(--claude-cli-allow-tools / OMNI_DEV_CLAUDE_CLI_ALLOW_TOOLS). \
The nested session can now read, edit, and execute against the \
environment it inherits."
);
}
if self.allow_mcp {
warn!(
"claude -p sandbox weakened: MCP-access escape hatch is enabled \
(--claude-cli-allow-mcp / OMNI_DEV_CLAUDE_CLI_ALLOW_MCP). \
The nested session can now load MCP servers configured in \
~/.claude/settings.json, exposing any OAuth tokens or \
network-attached services they hold."
);
}
info!(
binary = %self.binary_path.display(),
model = %self.model,
allow_tools = self.allow_tools,
allow_mcp = self.allow_mcp,
timeout_secs = self.timeout.as_secs(),
"Spawning claude -p subprocess"
);
let mut child = spawn_with_etxtbsy_retry(&mut cmd).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::Error::from(ClaudeError::SubprocessBinaryMissing(
self.binary_path.display().to_string(),
))
} else {
anyhow::Error::from(ClaudeError::SubprocessSpawnFailed(e.to_string()))
}
})?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("Failed to capture claude subprocess stdin"))?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("Failed to capture claude subprocess stdout"))?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| anyhow!("Failed to capture claude subprocess stderr"))?;
let cap = self.stdout_cap;
let prompt_bytes = user_prompt.to_owned();
let io_result = tokio::time::timeout(self.timeout, async {
let write_task = async move {
match stdin.write_all(prompt_bytes.as_bytes()).await {
Ok(()) => {}
Err(e) if is_pipe_closed(&e) => {
debug!("claude subprocess closed stdin before prompt fully written");
return Ok::<(), anyhow::Error>(());
}
Err(e) => {
return Err(anyhow::Error::from(e)
.context("Failed to write prompt to claude subprocess stdin"));
}
}
match stdin.shutdown().await {
Ok(()) => {}
Err(e) if is_pipe_closed(&e) => {
debug!("claude subprocess stdin already closed at shutdown");
}
Err(e) => {
return Err(anyhow::Error::from(e)
.context("Failed to close claude subprocess stdin"));
}
}
Ok::<(), anyhow::Error>(())
};
let read_stdout_task = read_capped(&mut stdout, cap);
let read_stderr_task = async {
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf).await;
Ok::<Vec<u8>, anyhow::Error>(buf)
};
let ((), stdout_bytes, stderr_bytes) =
tokio::try_join!(write_task, read_stdout_task, read_stderr_task)?;
Ok::<_, anyhow::Error>((stdout_bytes, stderr_bytes))
})
.await;
let (stdout_bytes, stderr_bytes) = match io_result {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => {
kill_and_reap(&mut child).await;
return Err(e);
}
Err(_) => {
kill_and_reap(&mut child).await;
return Err(ClaudeError::SubprocessTimeout {
secs: self.timeout.as_secs(),
}
.into());
}
};
let status = child
.wait()
.await
.context("Failed to wait for claude subprocess")?;
let stderr_text = String::from_utf8_lossy(&stderr_bytes).to_string();
debug!(
exit_status = ?status,
stdout_bytes = stdout_bytes.len(),
stderr_bytes = stderr_bytes.len(),
"claude -p subprocess finished"
);
let envelope: JsonOutput = match serde_json::from_slice::<JsonOutput>(&stdout_bytes) {
Ok(env) => env,
Err(e) => {
let stdout_text = String::from_utf8_lossy(&stdout_bytes);
return Err(ClaudeError::SubprocessJsonParseFailed(format!(
"{e}; exit_status={status}; stdout={stdout_text}; stderr={stderr_text}"
))
.into());
}
};
if let Some(cost) = envelope.total_cost_usd {
info!(
total_cost_usd = cost,
max_budget_usd = ?self.max_budget_usd,
model = %self.model,
"claude -p invocation cost"
);
if let Some(budget) = self.max_budget_usd {
if cost > budget {
warn!(
total_cost_usd = cost,
max_budget_usd = budget,
"claude -p reported cost above the configured budget cap"
);
}
}
}
if envelope.is_error {
return Err(map_api_error(&envelope, &stderr_text));
}
if !status.success() {
return Err(ClaudeError::ApiRequestFailed(format!(
"claude -p exited with non-zero status ({status}); stderr={stderr_text}"
))
.into());
}
let result = strip_wrapping_code_fence(&envelope.result);
super::log_response_success("Claude CLI", &Ok(result.clone()));
Ok(result)
}
}
fn strip_wrapping_code_fence(raw: &str) -> String {
let trimmed = raw.trim();
let Some(after_open) = trimmed.strip_prefix("```") else {
return trimmed.to_string();
};
let body = match after_open.find('\n') {
Some(i) => &after_open[i + 1..],
None => return trimmed.to_string(),
};
let Some(without_trailing) = body.trim_end().strip_suffix("```") else {
return trimmed.to_string();
};
if without_trailing.contains("```") {
return trimmed.to_string();
}
without_trailing.trim_end().to_string()
}
impl AiClient for ClaudeCliAiClient {
fn send_request<'a>(
&'a self,
system_prompt: &'a str,
user_prompt: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move {
debug!(
system_prompt_len = system_prompt.len(),
user_prompt_len = user_prompt.len(),
model = %self.model,
"Preparing claude -p subprocess request"
);
self.run(system_prompt, user_prompt).await
})
}
fn capabilities(&self) -> AiClientCapabilities {
AiClientCapabilities {
supports_response_schema: true,
}
}
fn send_request_with_options<'a>(
&'a self,
system_prompt: &'a str,
user_prompt: &'a str,
options: RequestOptions,
) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move {
debug!(
system_prompt_len = system_prompt.len(),
user_prompt_len = user_prompt.len(),
has_schema = options.response_schema.is_some(),
model = %self.model,
"Preparing claude -p subprocess request (with options)"
);
self.run_with_options(system_prompt, user_prompt, &options)
.await
})
}
fn get_metadata(&self) -> AiClientMetadata {
let effective_id = resolve_alias(&self.model);
let (max_context_length, max_response_length) =
super::registry_model_limits(&effective_id, &None);
AiClientMetadata {
provider: "Claude CLI".to_string(),
model: self.model.clone(),
max_context_length,
max_response_length,
active_beta: None,
}
}
}
fn resolve_alias(model: &str) -> String {
match model {
"haiku" => "claude-haiku-4-5-20251001".to_string(),
"sonnet" => "claude-sonnet-4-6".to_string(),
"opus" => "claude-opus-4-6".to_string(),
other => other.to_string(),
}
}
fn map_api_error(env: &JsonOutput, stderr: &str) -> anyhow::Error {
let status = env.api_error_status;
let msg = &env.result;
match status {
Some(401 | 403) => ClaudeError::ApiRequestFailed(format!(
"claude -p authentication failed ({status:?}): {msg}; stderr={stderr}"
))
.into(),
Some(404) => {
ClaudeError::ApiRequestFailed(format!("claude -p reported unknown model (404): {msg}"))
.into()
}
Some(429) => ClaudeError::RateLimitExceeded.into(),
Some(code) if (500..=599).contains(&code) => ClaudeError::ApiRequestFailed(format!(
"claude -p transient API error ({code}): {msg}; stderr={stderr}"
))
.into(),
_ => ClaudeError::ApiRequestFailed(format!(
"claude -p reported error (api_error_status={status:?}): {msg}; stderr={stderr}"
))
.into(),
}
}
fn is_pipe_closed(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset
)
}
async fn read_capped<R>(reader: &mut R, cap: usize) -> Result<Vec<u8>>
where
R: AsyncReadExt + Unpin,
{
let mut buf = Vec::with_capacity(4096.min(cap));
let mut chunk = [0u8; 4096];
loop {
let n = reader
.read(&mut chunk)
.await
.context("Failed to read claude subprocess stdout")?;
if n == 0 {
break;
}
if buf.len().saturating_add(n) > cap {
warn!(cap, "claude subprocess stdout exceeded cap");
return Err(ClaudeError::SubprocessOutputTooLarge { limit: cap }.into());
}
buf.extend_from_slice(&chunk[..n]);
}
Ok(buf)
}
async fn kill_and_reap(child: &mut tokio::process::Child) {
#[cfg(unix)]
{
if let Some(pid) = child.id() {
let group = nix::unistd::Pid::from_raw(pid as i32);
if let Err(e) = nix::sys::signal::killpg(group, nix::sys::signal::Signal::SIGKILL) {
if e != nix::errno::Errno::ESRCH {
debug!(error = %e, pid, "killpg failed; falling back to direct kill");
let _ = child.start_kill();
}
}
} else {
}
}
#[cfg(not(unix))]
{
let _ = child.kill().await;
}
let _ = child.wait().await;
}
async fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result<tokio::process::Child> {
const ETXTBSY: i32 = 26;
const MAX_ATTEMPTS: u32 = 6;
let mut backoff = Duration::from_millis(5);
for attempt in 1..=MAX_ATTEMPTS {
match cmd.spawn() {
Ok(child) => return Ok(child),
Err(e) if e.raw_os_error() == Some(ETXTBSY) && attempt < MAX_ATTEMPTS => {
debug!(
attempt,
backoff_ms = backoff.as_millis() as u64,
"spawn hit ETXTBSY; retrying"
);
tokio::time::sleep(backoff).await;
backoff = backoff.saturating_mul(2);
}
Err(e) => return Err(e),
}
}
unreachable!("loop exits via return")
}
#[cfg(test)]
pub(crate) static CLI_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::ffi::OsStr;
use tempfile::TempDir;
fn client_with_defaults(model: &str) -> ClaudeCliAiClient {
ClaudeCliAiClient::new_with_config(
model.to_string(),
DEFAULT_TIMEOUT,
DEFAULT_STDOUT_CAP,
false,
PathBuf::from("claude"),
)
}
fn args_of(cmd: &Command) -> Vec<String> {
cmd.as_std()
.get_args()
.map(|s| s.to_string_lossy().into_owned())
.collect()
}
#[test]
fn build_command_includes_sandbox_flags() {
let cli = client_with_defaults("sonnet");
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys-prompt", tmp.path());
let args = args_of(&cmd);
assert!(args.contains(&"-p".to_string()), "missing -p: {args:?}");
assert!(
args.contains(&"--model".to_string()) && args.contains(&"sonnet".to_string()),
"missing --model sonnet: {args:?}"
);
assert!(
args.contains(&"--output-format".to_string()) && args.contains(&"json".to_string()),
"missing --output-format json: {args:?}"
);
assert!(
args.contains(&"--tools".to_string()),
"missing --tools: {args:?}"
);
assert!(
args.contains(&"--strict-mcp-config".to_string()),
"missing --strict-mcp-config: {args:?}"
);
assert!(
args.contains(&"--setting-sources".to_string()),
"missing --setting-sources: {args:?}"
);
assert!(
args.contains(&"--disable-slash-commands".to_string()),
"missing --disable-slash-commands: {args:?}"
);
assert!(
args.contains(&"--no-session-persistence".to_string()),
"missing --no-session-persistence: {args:?}"
);
assert!(
args.contains(&"--permission-mode".to_string())
&& args.contains(&"default".to_string()),
"missing --permission-mode default: {args:?}"
);
assert!(
args.contains(&"--system-prompt".to_string()),
"missing --system-prompt: {args:?}"
);
}
#[test]
fn build_command_does_not_include_add_dir() {
let cli = client_with_defaults("sonnet");
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
assert!(
!args.contains(&"--add-dir".to_string()),
"must not pass --add-dir: {args:?}"
);
assert!(
!args.contains(&"--mcp-config".to_string()),
"must not pass --mcp-config (strict-mcp-config with no config = lockdown)"
);
}
#[test]
fn build_command_uses_temp_cwd_not_parent() {
let cli = client_with_defaults("sonnet");
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
assert_eq!(
cmd.as_std().get_current_dir().map(Path::to_path_buf),
Some(tmp.path().to_path_buf())
);
}
#[test]
fn build_command_appends_tool_suppression_in_system_prompt() {
let cli = client_with_defaults("sonnet");
let tmp = TempDir::new().unwrap();
let with_suffix = format!("my system prompt{TOOL_SUPPRESSION_SUFFIX}");
let cmd = cli.build_command(&with_suffix, tmp.path());
let args = args_of(&cmd);
let sys_idx = args
.iter()
.position(|a| a == "--system-prompt")
.expect("--system-prompt present");
let sys_val = &args[sys_idx + 1];
assert!(
sys_val.contains("Do not emit function_calls XML"),
"system prompt should contain tool-suppression instruction: {sys_val}"
);
}
#[test]
fn build_command_scrubs_claude_project_env() {
std::env::set_var("CLAUDE_PROJECT_DIR", "/should/not/leak");
std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "cli");
std::env::set_var("CLAUDE_PROJECT_SOMETHING", "x");
let cli = client_with_defaults("sonnet");
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let env: Vec<_> = cmd
.as_std()
.get_envs()
.map(|(k, v)| (k.to_string_lossy().into_owned(), v.map(OsStr::to_os_string)))
.collect();
let was_removed = |key: &str| -> bool { env.iter().any(|(k, v)| k == key && v.is_none()) };
assert!(
was_removed("CLAUDE_PROJECT_DIR"),
"CLAUDE_PROJECT_DIR should be scrubbed: {env:?}"
);
assert!(
was_removed("CLAUDE_CODE_ENTRYPOINT"),
"CLAUDE_CODE_ENTRYPOINT should be scrubbed: {env:?}"
);
assert!(
was_removed("CLAUDE_PROJECT_SOMETHING"),
"CLAUDE_PROJECT_SOMETHING should be scrubbed: {env:?}"
);
std::env::remove_var("CLAUDE_PROJECT_DIR");
std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
std::env::remove_var("CLAUDE_PROJECT_SOMETHING");
}
#[test]
fn build_command_with_allow_tools_omits_tools_flag() {
let cli = ClaudeCliAiClient::new_with_config(
"sonnet".to_string(),
DEFAULT_TIMEOUT,
DEFAULT_STDOUT_CAP,
true,
PathBuf::from("claude"),
);
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
assert!(
!args.contains(&"--tools".to_string()),
"allow_tools=true should not pass --tools: {args:?}"
);
}
fn build_args(allow_tools: bool, allow_mcp: bool) -> Vec<String> {
let cli = ClaudeCliAiClient::new_with_config(
"sonnet".to_string(),
DEFAULT_TIMEOUT,
DEFAULT_STDOUT_CAP,
allow_tools,
PathBuf::from("claude"),
)
.with_allow_mcp(allow_mcp);
let tmp = TempDir::new().unwrap();
args_of(&cli.build_command("sys", tmp.path()))
}
#[test]
fn matrix_default_includes_both_guards() {
let args = build_args(false, false);
assert!(
args.contains(&"--tools".to_string()),
"default must keep --tools: {args:?}"
);
assert!(
args.contains(&"--strict-mcp-config".to_string()),
"default must keep --strict-mcp-config: {args:?}"
);
}
#[test]
fn matrix_allow_mcp_alone_drops_only_strict_mcp() {
let args = build_args(false, true);
assert!(
args.contains(&"--tools".to_string()),
"allow_mcp alone must keep --tools: {args:?}"
);
assert!(
!args.contains(&"--strict-mcp-config".to_string()),
"allow_mcp alone must drop --strict-mcp-config: {args:?}"
);
}
#[test]
fn matrix_allow_tools_alone_drops_only_tools() {
let args = build_args(true, false);
assert!(
!args.contains(&"--tools".to_string()),
"allow_tools alone must drop --tools: {args:?}"
);
assert!(
args.contains(&"--strict-mcp-config".to_string()),
"allow_tools alone must keep --strict-mcp-config: {args:?}"
);
}
#[test]
fn matrix_both_drops_both_guards() {
let args = build_args(true, true);
assert!(
!args.contains(&"--tools".to_string()),
"both flags must drop --tools: {args:?}"
);
assert!(
!args.contains(&"--strict-mcp-config".to_string()),
"both flags must drop --strict-mcp-config: {args:?}"
);
}
struct AllowToolsEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
saved: Option<String>,
}
impl AllowToolsEnvGuard {
fn new() -> Self {
let lock = CLI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let saved = std::env::var(ALLOW_TOOLS_ENV_VAR).ok();
std::env::remove_var(ALLOW_TOOLS_ENV_VAR);
Self { _lock: lock, saved }
}
fn set(&self, value: &str) {
std::env::set_var(ALLOW_TOOLS_ENV_VAR, value);
}
}
impl Drop for AllowToolsEnvGuard {
fn drop(&mut self) {
match self.saved.take() {
Some(v) => std::env::set_var(ALLOW_TOOLS_ENV_VAR, v),
None => std::env::remove_var(ALLOW_TOOLS_ENV_VAR),
}
}
}
#[test]
fn allow_tools_from_env_defaults_to_false_when_unset() {
let _g = AllowToolsEnvGuard::new();
assert!(!ClaudeCliAiClient::allow_tools_from_env());
}
#[test]
fn allow_tools_from_env_true() {
let g = AllowToolsEnvGuard::new();
g.set("true");
assert!(ClaudeCliAiClient::allow_tools_from_env());
}
#[test]
fn allow_tools_from_env_true_case_insensitive_and_trimmed() {
let g = AllowToolsEnvGuard::new();
g.set(" TRUE ");
assert!(ClaudeCliAiClient::allow_tools_from_env());
}
#[test]
fn allow_tools_from_env_one_and_yes_accepted() {
let g = AllowToolsEnvGuard::new();
g.set("1");
assert!(ClaudeCliAiClient::allow_tools_from_env());
g.set("yes");
assert!(ClaudeCliAiClient::allow_tools_from_env());
}
#[test]
fn allow_tools_from_env_other_values_are_false() {
let g = AllowToolsEnvGuard::new();
for v in ["false", "0", "no", "off", "TRUE1", "YES!", ""] {
g.set(v);
assert!(
!ClaudeCliAiClient::allow_tools_from_env(),
"value {v:?} should not enable the escape hatch"
);
}
}
#[test]
fn new_picks_up_allow_tools_env_var() {
let g = AllowToolsEnvGuard::new();
g.set("true");
let cli = ClaudeCliAiClient::new("sonnet".to_string());
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
assert!(
!args.contains(&"--tools".to_string()),
"ALLOW_TOOLS=true should omit --tools in argv: {args:?}"
);
}
#[test]
fn new_defaults_to_tools_disabled_when_env_unset() {
let _g = AllowToolsEnvGuard::new();
let cli = ClaudeCliAiClient::new("sonnet".to_string());
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
assert!(
args.contains(&"--tools".to_string()),
"default (no env) must include --tools \"\": {args:?}"
);
}
struct AllowMcpEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
saved: Option<String>,
}
impl AllowMcpEnvGuard {
fn new() -> Self {
let lock = CLI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let saved = std::env::var(ALLOW_MCP_ENV_VAR).ok();
std::env::remove_var(ALLOW_MCP_ENV_VAR);
Self { _lock: lock, saved }
}
fn set(&self, value: &str) {
std::env::set_var(ALLOW_MCP_ENV_VAR, value);
}
}
impl Drop for AllowMcpEnvGuard {
fn drop(&mut self) {
match self.saved.take() {
Some(v) => std::env::set_var(ALLOW_MCP_ENV_VAR, v),
None => std::env::remove_var(ALLOW_MCP_ENV_VAR),
}
}
}
#[test]
fn allow_mcp_from_env_defaults_to_false_when_unset() {
let _g = AllowMcpEnvGuard::new();
assert!(!ClaudeCliAiClient::allow_mcp_from_env());
}
#[test]
fn allow_mcp_from_env_true() {
let g = AllowMcpEnvGuard::new();
g.set("true");
assert!(ClaudeCliAiClient::allow_mcp_from_env());
}
#[test]
fn allow_mcp_from_env_true_case_insensitive_and_trimmed() {
let g = AllowMcpEnvGuard::new();
g.set(" TRUE ");
assert!(ClaudeCliAiClient::allow_mcp_from_env());
}
#[test]
fn allow_mcp_from_env_one_and_yes_accepted() {
let g = AllowMcpEnvGuard::new();
g.set("1");
assert!(ClaudeCliAiClient::allow_mcp_from_env());
g.set("yes");
assert!(ClaudeCliAiClient::allow_mcp_from_env());
}
#[test]
fn allow_mcp_from_env_other_values_are_false() {
let g = AllowMcpEnvGuard::new();
for v in ["false", "0", "no", "off", "TRUE1", "YES!", ""] {
g.set(v);
assert!(
!ClaudeCliAiClient::allow_mcp_from_env(),
"value {v:?} should not enable the escape hatch"
);
}
}
#[test]
fn new_picks_up_allow_mcp_env_var() {
let mcp_guard = AllowMcpEnvGuard::new();
let saved_tools = std::env::var(ALLOW_TOOLS_ENV_VAR).ok();
std::env::remove_var(ALLOW_TOOLS_ENV_VAR);
mcp_guard.set("true");
let cli = ClaudeCliAiClient::new("sonnet".to_string());
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
match saved_tools {
Some(v) => std::env::set_var(ALLOW_TOOLS_ENV_VAR, v),
None => std::env::remove_var(ALLOW_TOOLS_ENV_VAR),
}
assert!(
!args.contains(&"--strict-mcp-config".to_string()),
"ALLOW_MCP=true should omit --strict-mcp-config: {args:?}"
);
assert!(
args.contains(&"--tools".to_string()),
"ALLOW_MCP=true alone should keep --tools: {args:?}"
);
}
#[test]
fn new_defaults_to_strict_mcp_when_env_unset() {
let _g = AllowMcpEnvGuard::new();
let cli = ClaudeCliAiClient::new("sonnet".to_string());
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
assert!(
args.contains(&"--strict-mcp-config".to_string()),
"default (no env) must include --strict-mcp-config: {args:?}"
);
}
struct MaxBudgetEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
saved: Option<String>,
}
impl MaxBudgetEnvGuard {
fn new() -> Self {
let lock = CLI_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let saved = std::env::var(MAX_BUDGET_ENV_VAR).ok();
std::env::remove_var(MAX_BUDGET_ENV_VAR);
Self { _lock: lock, saved }
}
fn set(&self, value: &str) {
std::env::set_var(MAX_BUDGET_ENV_VAR, value);
}
}
impl Drop for MaxBudgetEnvGuard {
fn drop(&mut self) {
match self.saved.take() {
Some(v) => std::env::set_var(MAX_BUDGET_ENV_VAR, v),
None => std::env::remove_var(MAX_BUDGET_ENV_VAR),
}
}
}
#[test]
fn max_budget_from_env_unset_is_none() {
let _g = MaxBudgetEnvGuard::new();
assert!(ClaudeCliAiClient::max_budget_from_env().is_none());
}
#[test]
fn max_budget_from_env_parses_decimal() {
let g = MaxBudgetEnvGuard::new();
g.set("0.50");
assert_eq!(ClaudeCliAiClient::max_budget_from_env(), Some(0.50));
g.set("2.5");
assert_eq!(ClaudeCliAiClient::max_budget_from_env(), Some(2.5));
}
#[test]
fn max_budget_from_env_trims_whitespace() {
let g = MaxBudgetEnvGuard::new();
g.set(" 1.25 ");
assert_eq!(ClaudeCliAiClient::max_budget_from_env(), Some(1.25));
}
#[test]
fn max_budget_from_env_rejects_non_positive() {
let g = MaxBudgetEnvGuard::new();
g.set("0");
assert!(ClaudeCliAiClient::max_budget_from_env().is_none());
g.set("-1.0");
assert!(ClaudeCliAiClient::max_budget_from_env().is_none());
}
#[test]
fn max_budget_from_env_rejects_non_finite() {
let g = MaxBudgetEnvGuard::new();
g.set("nan");
assert!(ClaudeCliAiClient::max_budget_from_env().is_none());
g.set("inf");
assert!(ClaudeCliAiClient::max_budget_from_env().is_none());
}
#[test]
fn max_budget_from_env_rejects_garbage() {
let g = MaxBudgetEnvGuard::new();
g.set("five dollars");
assert!(ClaudeCliAiClient::max_budget_from_env().is_none());
g.set("");
assert!(ClaudeCliAiClient::max_budget_from_env().is_none());
}
#[test]
fn build_command_omits_max_budget_when_unset() {
let cli = client_with_defaults("sonnet");
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
assert!(
!args.contains(&"--max-budget-usd".to_string()),
"no budget → argv must omit --max-budget-usd: {args:?}"
);
}
#[test]
fn build_command_includes_max_budget_when_set() {
let cli = client_with_defaults("sonnet").with_max_budget_usd(Some(0.50));
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
let idx = args
.iter()
.position(|a| a == "--max-budget-usd")
.expect("argv should contain --max-budget-usd");
assert_eq!(args[idx + 1], "0.5");
}
#[test]
fn new_picks_up_max_budget_env_var() {
let g = MaxBudgetEnvGuard::new();
g.set("1.25");
let cli = ClaudeCliAiClient::new("sonnet".to_string());
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
let idx = args
.iter()
.position(|a| a == "--max-budget-usd")
.expect("argv should contain --max-budget-usd when env set");
assert_eq!(args[idx + 1], "1.25");
}
#[test]
fn with_max_budget_usd_none_clears_budget() {
let cli = client_with_defaults("sonnet")
.with_max_budget_usd(Some(1.0))
.with_max_budget_usd(None);
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command("sys", tmp.path());
let args = args_of(&cmd);
assert!(!args.contains(&"--max-budget-usd".to_string()));
}
#[tokio::test]
#[cfg(unix)]
async fn cost_is_extracted_from_json_envelope_and_run_succeeds() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(
&tmp,
r#"{"is_error":false,"result":"ok","total_cost_usd":0.0123}"#,
0,
);
let cli = client_with_shim(shim).with_max_budget_usd(Some(1.0));
let out = cli.run("sys", "user").await.unwrap();
assert_eq!(out, "ok");
}
#[test]
fn resolve_alias_known() {
assert_eq!(resolve_alias("haiku"), "claude-haiku-4-5-20251001");
assert_eq!(resolve_alias("sonnet"), "claude-sonnet-4-6");
assert_eq!(resolve_alias("opus"), "claude-opus-4-6");
}
#[test]
fn resolve_alias_passthrough() {
assert_eq!(resolve_alias("claude-sonnet-4-6"), "claude-sonnet-4-6");
assert_eq!(
resolve_alias("claude-haiku-4-5-20251001"),
"claude-haiku-4-5-20251001"
);
}
#[test]
fn metadata_has_claude_cli_provider() {
let cli = client_with_defaults("sonnet");
let meta = cli.get_metadata();
assert_eq!(meta.provider, "Claude CLI");
assert_eq!(meta.model, "sonnet");
assert!(meta.max_context_length > 0);
assert!(meta.max_response_length > 0);
assert!(meta.active_beta.is_none());
}
#[test]
fn metadata_prompt_style_is_claude() {
use crate::claude::ai::PromptStyle;
let cli = client_with_defaults("sonnet");
assert_eq!(cli.get_metadata().prompt_style(), PromptStyle::Claude);
}
#[test]
fn capabilities_advertise_response_schema_support() {
let cli = client_with_defaults("sonnet");
let caps = cli.capabilities();
assert!(
caps.supports_response_schema,
"claude-cli backend should advertise response-schema support"
);
}
#[test]
fn build_command_omits_json_schema_when_no_path_given() {
let cli = client_with_defaults("sonnet");
let tmp = TempDir::new().unwrap();
let cmd = cli.build_command_with_schema("sys", tmp.path(), None);
let args = args_of(&cmd);
assert!(
!args.contains(&"--json-schema".to_string()),
"no schema → argv must omit --json-schema: {args:?}"
);
}
#[test]
fn build_command_includes_json_schema_when_path_given() {
let cli = client_with_defaults("sonnet");
let tmp = TempDir::new().unwrap();
let schema_path = tmp.path().join("schema.json");
let cmd = cli.build_command_with_schema("sys", tmp.path(), Some(&schema_path));
let args = args_of(&cmd);
let idx = args
.iter()
.position(|a| a == "--json-schema")
.expect("argv should contain --json-schema");
assert_eq!(args[idx + 1], schema_path.to_string_lossy());
}
#[tokio::test]
#[cfg(unix)]
async fn run_with_options_writes_schema_and_succeeds() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(&tmp, r#"{"is_error":false,"result":"ok"}"#, 0);
let cli = client_with_shim(shim);
let schema = serde_json::json!({
"type": "object",
"additionalProperties": false,
"required": ["title"],
"properties": {"title": {"type": "string"}}
});
let opts = RequestOptions::default().with_response_schema(schema);
let out = cli
.run_with_options("sys", "user", &opts)
.await
.expect("run_with_options should succeed");
assert_eq!(out, "ok");
}
#[tokio::test]
#[cfg(unix)]
async fn send_request_with_options_default_acts_like_send_request() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(&tmp, r#"{"is_error":false,"result":"hi"}"#, 0);
let cli = client_with_shim(shim);
let out = cli
.send_request_with_options("sys", "user", RequestOptions::default())
.await
.expect("send_request_with_options should succeed");
assert_eq!(out, "hi");
}
#[tokio::test]
#[cfg(unix)]
async fn send_request_with_options_records_debug_trace() {
let _guard = shim_lock();
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_test_writer()
.try_init();
let tmp = TempDir::new().unwrap();
let shim = make_shim(&tmp, r#"{"is_error":false,"result":"hi"}"#, 0);
let cli = client_with_shim(shim);
let schema = serde_json::json!({"type": "object", "additionalProperties": false});
let out = cli
.send_request_with_options(
"sys",
"user",
RequestOptions::default().with_response_schema(schema),
)
.await
.expect("send_request_with_options should succeed");
assert_eq!(out, "hi");
}
#[test]
fn strip_fence_removes_yaml_wrapping() {
let raw = "```yaml\namendments: []\n```";
assert_eq!(strip_wrapping_code_fence(raw), "amendments: []");
}
#[test]
fn strip_fence_removes_bare_wrapping() {
let raw = "```\nsome text\n```";
assert_eq!(strip_wrapping_code_fence(raw), "some text");
}
#[test]
fn strip_fence_preserves_bare_content() {
let raw = "amendments:\n - hash: abc";
assert_eq!(strip_wrapping_code_fence(raw), raw);
}
#[test]
fn strip_fence_preserves_content_with_internal_fences() {
let raw = "title: PR title\ndescription: |\n Here is code:\n ```rust\n fn x() {}\n ```\n Done.";
assert_eq!(strip_wrapping_code_fence(raw), raw);
}
#[test]
fn strip_fence_with_wrapper_around_internal_fences_is_left_alone() {
let raw = "```markdown\nouter\n```rust\nfn x() {}\n```\nmore\n```";
assert_eq!(strip_wrapping_code_fence(raw), raw);
}
#[test]
fn strip_fence_trims_outer_whitespace() {
let raw = "\n\n```yaml\namendments: []\n```\n\n";
assert_eq!(strip_wrapping_code_fence(raw), "amendments: []");
}
#[tokio::test]
async fn spawn_missing_binary_yields_typed_error() {
let cli = ClaudeCliAiClient::new_with_config(
"sonnet".to_string(),
DEFAULT_TIMEOUT,
DEFAULT_STDOUT_CAP,
false,
PathBuf::from("/nonexistent/path/to/claude-binary-xyz"),
);
let err = cli
.run("sys", "user")
.await
.expect_err("expected missing-binary error");
let chain = format!("{err:#}");
assert!(
chain.contains("Subprocess binary not found"),
"unexpected error: {chain}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn runaway_output_yields_timeout_or_cap_error() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = tmp.path().join("runaway-claude");
write_exec_script(&shim, "#!/bin/sh\nwhile true; do printf 'y\\n'; done\n");
let cli = ClaudeCliAiClient::new_with_config(
"sonnet".to_string(),
Duration::from_secs(1),
64 * 1024,
false,
shim,
);
let err = cli
.run("sys", "user")
.await
.expect_err("expected timeout or size-cap error");
let chain = format!("{err:#}");
assert!(
chain.contains("timed out") || chain.contains("output exceeded"),
"unexpected error: {chain}"
);
}
#[tokio::test]
async fn non_json_output_yields_typed_error() {
let cli = ClaudeCliAiClient::new_with_config(
"sonnet".to_string(),
DEFAULT_TIMEOUT,
DEFAULT_STDOUT_CAP,
false,
PathBuf::from("/bin/echo"),
);
let err = cli
.run("sys", "user")
.await
.expect_err("expected JSON parse error");
let chain = format!("{err:#}");
assert!(
chain.contains("invalid JSON output"),
"unexpected error: {chain}"
);
}
#[cfg(unix)]
use crate::test_support::shim::{shim_lock, write_exec_script};
#[cfg(unix)]
#[test]
fn shim_lock_recovers_from_poison() {
let _ = std::thread::spawn(|| {
let _g = shim_lock();
panic!("intentional: poisoning SHIM_LOCK for coverage");
})
.join();
let _g = shim_lock();
}
#[cfg(unix)]
fn make_shim(tmp: &TempDir, body: &str, exit_code: i32) -> PathBuf {
let shim = tmp.path().join("claude-shim");
let script = format!(
"#!/bin/sh\ncat >/dev/null\nprintf '%s' '{}'\nexit {}\n",
body.replace('\'', "'\\''"),
exit_code
);
write_exec_script(&shim, &script);
shim
}
#[cfg(unix)]
fn client_with_shim(shim: PathBuf) -> ClaudeCliAiClient {
ClaudeCliAiClient::new_with_config(
"sonnet".to_string(),
Duration::from_secs(10),
DEFAULT_STDOUT_CAP,
false,
shim,
)
}
#[tokio::test]
#[cfg(unix)]
async fn success_returns_result_field() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(&tmp, r#"{"is_error":false,"result":"hello from shim"}"#, 0);
let out = client_with_shim(shim).run("sys", "user").await.unwrap();
assert_eq!(out, "hello from shim");
}
#[tokio::test]
#[cfg(unix)]
async fn success_strips_top_level_yaml_fence() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(
&tmp,
r#"{"is_error":false,"result":"```yaml\namendments: []\n```"}"#,
0,
);
let out = client_with_shim(shim).run("sys", "user").await.unwrap();
assert_eq!(out, "amendments: []");
}
#[tokio::test]
#[cfg(unix)]
async fn is_error_401_maps_to_auth_failure() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(
&tmp,
r#"{"is_error":true,"api_error_status":401,"result":"unauthorized"}"#,
0,
);
let err = client_with_shim(shim)
.run("sys", "user")
.await
.expect_err("expected auth error");
let chain = format!("{err:#}");
assert!(
chain.contains("authentication failed"),
"unexpected error: {chain}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn is_error_403_maps_to_auth_failure() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(
&tmp,
r#"{"is_error":true,"api_error_status":403,"result":"forbidden"}"#,
0,
);
let err = client_with_shim(shim)
.run("sys", "user")
.await
.expect_err("expected auth error");
let chain = format!("{err:#}");
assert!(
chain.contains("authentication failed"),
"unexpected error: {chain}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn is_error_404_maps_to_unknown_model() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(
&tmp,
r#"{"is_error":true,"api_error_status":404,"result":"model not found"}"#,
0,
);
let err = client_with_shim(shim)
.run("sys", "user")
.await
.expect_err("expected unknown-model error");
let chain = format!("{err:#}");
assert!(chain.contains("unknown model"), "unexpected error: {chain}");
}
#[tokio::test]
#[cfg(unix)]
async fn is_error_429_maps_to_rate_limit() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(
&tmp,
r#"{"is_error":true,"api_error_status":429,"result":"too many"}"#,
0,
);
let err = client_with_shim(shim)
.run("sys", "user")
.await
.expect_err("expected rate-limit error");
let downcast = err
.downcast_ref::<ClaudeError>()
.expect("error should be ClaudeError");
assert!(matches!(downcast, ClaudeError::RateLimitExceeded));
}
#[tokio::test]
#[cfg(unix)]
async fn is_error_500_maps_to_transient() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(
&tmp,
r#"{"is_error":true,"api_error_status":503,"result":"upstream unavailable"}"#,
0,
);
let err = client_with_shim(shim)
.run("sys", "user")
.await
.expect_err("expected transient error");
let chain = format!("{err:#}");
assert!(
chain.contains("transient API error"),
"unexpected error: {chain}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn is_error_unknown_status_maps_to_generic() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(
&tmp,
r#"{"is_error":true,"result":"something went wrong"}"#,
0,
);
let err = client_with_shim(shim)
.run("sys", "user")
.await
.expect_err("expected generic error");
let chain = format!("{err:#}");
assert!(
chain.contains("reported error"),
"unexpected error: {chain}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn non_zero_exit_with_clean_json_still_errors() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = make_shim(&tmp, r#"{"is_error":false,"result":"ok"}"#, 1);
let err = client_with_shim(shim)
.run("sys", "user")
.await
.expect_err("expected exit-status error");
let chain = format!("{err:#}");
assert!(
chain.contains("non-zero status"),
"unexpected error: {chain}"
);
}
#[tokio::test]
#[cfg(target_os = "linux")]
async fn spawn_retries_through_etxtbsy() {
let _guard = shim_lock();
use std::os::unix::fs::OpenOptionsExt;
let tmp = TempDir::new().unwrap();
let shim = tmp.path().join("busy-shim");
write_exec_script(
&shim,
"#!/bin/sh\ncat >/dev/null\nprintf '%s' '{\"is_error\":false,\"result\":\"late\"}'\nexit 0\n",
);
let blocker = std::fs::OpenOptions::new()
.write(true)
.mode(0o755)
.open(&shim)
.unwrap();
let drop_after = Duration::from_millis(20);
let release = tokio::spawn(async move {
tokio::time::sleep(drop_after).await;
drop(blocker);
});
let out = client_with_shim(shim).run("sys", "user").await.unwrap();
release.await.unwrap();
assert_eq!(out, "late");
}
#[cfg(unix)]
async fn wait_for_pid_gone(pid: i32, deadline: Duration) -> Result<(), Duration> {
let nix_pid = nix::unistd::Pid::from_raw(pid);
let start = std::time::Instant::now();
loop {
if nix::sys::signal::kill(nix_pid, None) == Err(nix::errno::Errno::ESRCH) {
return Ok(());
}
if start.elapsed() >= deadline {
return Err(start.elapsed());
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
#[cfg(unix)]
async fn timeout_reaps_full_process_group() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let pid_file = tmp.path().join("sleeper.pid");
let shim = tmp.path().join("group-shim");
let script = format!(
"#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > '{}'\nsleep 30\n",
pid_file.display()
);
write_exec_script(&shim, &script);
let cli = ClaudeCliAiClient::new_with_config(
"sonnet".to_string(),
Duration::from_millis(500),
DEFAULT_STDOUT_CAP,
false,
shim,
);
let err = cli
.run("sys", "user")
.await
.expect_err("expected timeout error");
let chain = format!("{err:#}");
assert!(chain.contains("timed out"), "expected timeout: {chain}");
let pid_str = std::fs::read_to_string(&pid_file)
.expect("shim should have recorded sleeper PID before sleeping");
let sleeper_pid: i32 = pid_str.trim().parse().expect("valid pid");
wait_for_pid_gone(sleeper_pid, Duration::from_secs(3))
.await
.unwrap_or_else(|elapsed| {
panic!(
"background sleeper {sleeper_pid} still alive {elapsed:?} after \
parent timeout — process-group reap regressed (issue #633)"
)
});
}
#[tokio::test]
#[cfg(unix)]
async fn kill_and_reap_kills_full_process_group() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let pid_file = tmp.path().join("sleeper-direct.pid");
let shim = tmp.path().join("direct-shim");
let script = format!(
"#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > '{}'\nsleep 30\n",
pid_file.display()
);
write_exec_script(&shim, &script);
let mut cmd = tokio::process::Command::new(&shim);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
let mut child = spawn_with_etxtbsy_retry(&mut cmd)
.await
.expect("spawn shim");
let pid_path = pid_file.clone();
let pid_str = tokio::time::timeout(Duration::from_secs(2), async move {
loop {
if let Ok(s) = std::fs::read_to_string(&pid_path) {
if !s.trim().is_empty() {
return s;
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("shim wrote PID");
let sleeper_pid: i32 = pid_str.trim().parse().expect("valid pid");
kill_and_reap(&mut child).await;
wait_for_pid_gone(sleeper_pid, Duration::from_secs(3))
.await
.unwrap_or_else(|elapsed| {
panic!(
"background sleeper {sleeper_pid} still alive {elapsed:?} after \
kill_and_reap"
)
});
}
#[tokio::test]
#[cfg(unix)]
async fn kill_and_reap_tolerates_already_exited_child() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = tmp.path().join("fast-exit");
write_exec_script(&shim, "#!/bin/sh\nexit 0\n");
let mut cmd = tokio::process::Command::new(&shim);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
let mut child = spawn_with_etxtbsy_retry(&mut cmd)
.await
.expect("spawn shim");
tokio::time::sleep(Duration::from_millis(50)).await;
kill_and_reap(&mut child).await;
}
#[tokio::test]
#[cfg(unix)]
async fn kill_and_reap_handles_already_waited_child() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = tmp.path().join("waited-shim");
write_exec_script(&shim, "#!/bin/sh\nexit 0\n");
let mut cmd = tokio::process::Command::new(&shim);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
let mut child = spawn_with_etxtbsy_retry(&mut cmd)
.await
.expect("spawn shim");
let _ = child.wait().await.expect("wait succeeds");
assert!(child.id().is_none(), "post-wait id should be None");
kill_and_reap(&mut child).await;
}
#[tokio::test]
#[cfg(unix)]
async fn wait_for_pid_gone_returns_err_on_deadline() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = tmp.path().join("alive-long");
write_exec_script(&shim, "#!/bin/sh\nsleep 30\n");
let mut cmd = tokio::process::Command::new(&shim);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
let mut child = spawn_with_etxtbsy_retry(&mut cmd)
.await
.expect("spawn shim");
let pid = child.id().expect("has pid") as i32;
let deadline = Duration::from_millis(80);
let res = wait_for_pid_gone(pid, deadline).await;
let elapsed = res.expect_err("should hit deadline before sleeper exits");
assert!(
elapsed >= deadline,
"elapsed {elapsed:?} should be at least the deadline {deadline:?}"
);
kill_and_reap(&mut child).await;
}
#[tokio::test]
#[cfg(unix)]
async fn kill_and_reap_reaps_running_child() {
let _guard = shim_lock();
let tmp = TempDir::new().unwrap();
let shim = tmp.path().join("running-shim");
write_exec_script(&shim, "#!/bin/sh\nsleep 30\n");
let mut cmd = tokio::process::Command::new(&shim);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0);
let mut child = spawn_with_etxtbsy_retry(&mut cmd)
.await
.expect("spawn shim");
let pid = child.id().expect("child has pid before reap");
kill_and_reap(&mut child).await;
wait_for_pid_gone(pid as i32, Duration::from_secs(3))
.await
.unwrap_or_else(|elapsed| {
panic!("direct child {pid} still alive {elapsed:?} after kill_and_reap")
});
}
}