use std::ffi::OsStr;
use std::io::Write;
use std::process::Stdio;
use std::time::{Duration, Instant};
use crate::types::RunnerError;
use tempfile::{Builder as TempFileBuilder, TempPath};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::{ChildStderr, ChildStdin, ChildStdout, Command};
use tokio::time::timeout as tokio_timeout;
use tracing::{debug, info, trace, warn};
const DEFAULT_MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024;
const REDACTED_VALUE_FLAGS: &[&str] = &[
"-p",
"--prompt",
"--system-prompt",
"--append-system-prompt",
"--mcp-config",
"--additional-mcp-config",
];
const MAX_TRACED_ARG_BYTES: usize = 128;
pub(crate) fn redacted_argv<'a>(args: impl IntoIterator<Item = &'a OsStr>) -> Vec<String> {
let mut traced = Vec::new();
let mut value_follows = false;
for arg in args {
let text = arg.to_string_lossy();
let is_value = value_follows && !text.starts_with("--");
value_follows = REDACTED_VALUE_FLAGS.contains(&text.as_ref());
let flag_with_value = text
.split_once('=')
.filter(|(flag, _)| REDACTED_VALUE_FLAGS.contains(flag));
traced.push(if is_value {
redaction(arg)
} else if let Some((flag, value)) = flag_with_value {
format!("{flag}={}", redaction(OsStr::new(value)))
} else if text.len() > MAX_TRACED_ARG_BYTES {
redaction(arg)
} else {
text.into_owned()
});
}
traced
}
fn redaction(value: &OsStr) -> String {
format!("<redacted {} bytes>", value.len())
}
pub(crate) fn private_config_file(prefix: &str, contents: &str) -> Result<TempPath, RunnerError> {
let mut file = TempFileBuilder::new()
.prefix(prefix)
.suffix(".json")
.tempfile()
.map_err(|e| RunnerError::internal(format!("Failed to create a config file: {e}")))?;
file.write_all(contents.as_bytes())
.and_then(|()| file.flush())
.map_err(|e| RunnerError::internal(format!("Failed to write a config file: {e}")))?;
Ok(file.into_temp_path())
}
pub(crate) fn feed_stdin(stdin: Option<ChildStdin>, input: Vec<u8>) {
let Some(mut stdin) = stdin else {
warn!("CLI stdin was not piped; the input was not delivered");
return;
};
tokio::spawn(async move {
if let Err(e) = stdin.write_all(&input).await {
debug!(error = %e, "CLI closed stdin before reading all of its input");
return;
}
if let Err(e) = stdin.shutdown().await {
debug!(error = %e, "Closing CLI stdin failed");
}
});
}
#[derive(Debug, Clone)]
pub struct CliOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: i32,
pub duration: Duration,
}
async fn read_stdout_capped(stream: Option<ChildStdout>, limit: usize) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(mut reader) = stream {
let mut tmp = [0u8; 8192];
loop {
match reader.read(&mut tmp).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let remaining = limit.saturating_sub(buf.len());
buf.extend_from_slice(&tmp[..n.min(remaining)]);
if buf.len() >= limit {
warn!(limit_bytes = limit, "stdout output truncated at cap");
break;
}
}
}
}
}
buf
}
pub(crate) async fn read_stderr_capped(stream: Option<ChildStderr>, limit: usize) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(mut reader) = stream {
let mut tmp = [0u8; 8192];
loop {
match reader.read(&mut tmp).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let remaining = limit.saturating_sub(buf.len());
buf.extend_from_slice(&tmp[..n.min(remaining)]);
if buf.len() >= limit {
break;
}
}
}
}
}
buf
}
pub async fn run_cli_command(
cmd: &mut Command,
timeout: Duration,
max_output_bytes: usize,
) -> Result<CliOutput, RunnerError> {
run_cli_command_with_input(cmd, None, timeout, max_output_bytes).await
}
pub async fn run_cli_command_with_input(
cmd: &mut Command,
input: Option<&[u8]>,
timeout: Duration,
max_output_bytes: usize,
) -> Result<CliOutput, RunnerError> {
let effective_max = if max_output_bytes == 0 {
DEFAULT_MAX_OUTPUT_BYTES
} else {
max_output_bytes
};
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
if input.is_some() {
cmd.stdin(Stdio::piped());
}
if tracing::enabled!(tracing::Level::TRACE) {
let program = cmd.as_std().get_program().to_string_lossy().to_string();
let args = redacted_argv(cmd.as_std().get_args());
trace!(program = %program, args = ?args, "Spawning CLI process");
}
let start = Instant::now();
let mut child = cmd
.spawn()
.map_err(|e| RunnerError::internal(format!("Failed to spawn CLI process: {e}")))?;
if let Some(input) = input {
feed_stdin(child.stdin.take(), input.to_vec());
}
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
let stdout_task = tokio::spawn(read_stdout_capped(stdout_handle, effective_max));
let stderr_task = tokio::spawn(read_stderr_capped(stderr_handle, effective_max));
let wait_result = tokio_timeout(timeout, child.wait()).await;
let duration = start.elapsed();
match wait_result {
Ok(Ok(status)) => {
let exit_code = status.code().unwrap_or(-1);
let stdout = stdout_task.await.unwrap_or_default();
let stderr = stderr_task.await.unwrap_or_default();
info!(
exit_code,
stdout_len = stdout.len(),
stderr_len = stderr.len(),
latency_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
"CLI command completed"
);
if tracing::enabled!(tracing::Level::TRACE) {
trace!(
stdout = %String::from_utf8_lossy(&stdout),
"CLI command stdout"
);
if !stderr.is_empty() {
trace!(
stderr = %String::from_utf8_lossy(&stderr),
"CLI command stderr"
);
}
}
Ok(CliOutput {
stdout,
stderr,
exit_code,
duration,
})
}
Ok(Err(e)) => Err(RunnerError::internal(format!(
"Failed to wait for CLI process: {e}"
))),
Err(_) => {
warn!(?timeout, "CLI command timed out, killing process");
let _ = child.kill().await;
Err(RunnerError::timeout(format!(
"CLI command timed out after {timeout:?}"
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn traced(args: &[&str]) -> Vec<String> {
redacted_argv(args.iter().map(OsStr::new))
}
#[test]
fn prompts_and_mcp_configs_are_traced_by_size_only() {
let config = r#"{"mcpServers":{"dravr":{"type":"http","url":"http://127.0.0.1:9/mcp","headers":{"Authorization":"Bearer tok-secret"}}}}"#;
let args = traced(&[
"-p",
"my athlete's resting HR is 41",
"--system-prompt",
"You coach Jane Doe",
"--mcp-config",
config,
"--additional-mcp-config",
"@/tmp/embacle-mcp-x.json",
"--model",
"opus",
]);
assert_eq!(
args,
[
"-p",
"<redacted 29 bytes>",
"--system-prompt",
"<redacted 18 bytes>",
"--mcp-config",
&format!("<redacted {} bytes>", config.len()),
"--additional-mcp-config",
"<redacted 24 bytes>",
"--model",
"opus",
]
);
let joined = args.join(" ");
assert!(!joined.contains("Bearer tok-secret"), "{joined}");
assert!(!joined.contains("resting HR"), "{joined}");
}
#[test]
fn a_flag_followed_by_a_flag_took_no_value() {
assert_eq!(
traced(&["-p", "--format", "json", "short"]),
["-p", "--format", "json", "short"]
);
}
#[test]
fn the_equals_form_is_redacted_too() {
assert_eq!(
traced(&["--system-prompt=You coach Jane", "--output-format=json"]),
[
"--system-prompt=<redacted 14 bytes>",
"--output-format=json"
]
);
}
#[test]
fn a_long_positional_argument_is_free_text_and_redacted() {
let prompt = "x".repeat(MAX_TRACED_ARG_BYTES + 1);
let kept = "y".repeat(MAX_TRACED_ARG_BYTES);
assert_eq!(
traced(&["exec", &prompt, &kept]),
[
"exec".to_owned(),
format!("<redacted {} bytes>", MAX_TRACED_ARG_BYTES + 1),
kept
]
);
}
#[cfg(unix)]
#[test]
fn a_private_config_file_is_0600_and_removed_on_drop() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let path = private_config_file("embacle-test-", "{\"k\":1}").unwrap(); let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600);
assert_eq!(fs::read_to_string(&path).unwrap(), "{\"k\":1}"); let kept = path.to_path_buf();
drop(path);
assert!(!kept.exists(), "the file goes with its path");
}
}