use super::*;
#[derive(Debug, Clone)]
pub(crate) struct CommandRunOutput {
pub(crate) output: String,
pub(crate) exit_code: Option<i32>,
pub(crate) signal: Option<i32>,
pub(crate) stdout_lines: usize,
pub(crate) stderr_lines: usize,
}
pub(crate) enum CommandRunResult {
Completed(CommandRunOutput),
Detached { pid: u32, log_path: PathBuf },
Cancelled,
TimedOut,
}
pub(crate) const SECRET_ENV_VARS: &[&str] = &[
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OLLAMA_API_KEY",
"GROQ_API_KEY",
"MISTRAL_API_KEY",
"DEEPSEEK_API_KEY",
"OPENROUTER_API_KEY",
"XAI_API_KEY",
"TOGETHER_API_KEY",
"MERMAID_DAEMON_TOKEN",
];
pub(crate) fn harden_noninteractive_env(cmd: &mut Command) {
cmd.env("GIT_TERMINAL_PROMPT", "0");
}
pub(crate) fn scrub_secret_env(cmd: &mut Command) {
for name in secret_env_names() {
cmd.env_remove(&name);
}
}
pub(crate) fn secret_env_names() -> Vec<String> {
std::env::vars()
.map(|(name, _)| name)
.filter(|name| is_secret_env_name(name))
.collect()
}
pub(crate) fn is_secret_env_name(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
SECRET_ENV_VARS.contains(&upper.as_str())
|| upper.contains("API_KEY")
|| upper.contains("APIKEY")
|| upper.contains("ACCESS_KEY")
|| upper.contains("PRIVATE_KEY")
|| upper.contains("SECRET")
|| upper.contains("PASSWORD")
|| upper.contains("PASSWD")
|| upper.contains("CREDENTIAL")
|| upper.contains("TOKEN")
|| upper.contains("WEBHOOK")
|| upper.contains("DATABASE_URL")
|| upper.ends_with("_DSN")
|| upper.contains("CONNECTION_STRING")
|| upper == "KUBECONFIG"
|| upper == "SSH_AUTH_SOCK"
}
pub(crate) const TEE_LOG_CAP_BYTES: usize = 64 * 1024 * 1024;
pub(crate) struct CappedCapture {
pub(crate) head_cap: usize,
pub(crate) tail_cap: usize,
pub(crate) head: Vec<u8>,
pub(crate) tail: std::collections::VecDeque<u8>,
pub(crate) total: usize,
}
impl CappedCapture {
pub(crate) fn new(cap: usize) -> Self {
let head_cap = cap / 2;
Self {
head_cap,
tail_cap: cap - head_cap,
head: Vec::new(),
tail: std::collections::VecDeque::new(),
total: 0,
}
}
pub(crate) fn push(&mut self, mut chunk: &[u8]) {
self.total += chunk.len();
if self.head.len() < self.head_cap {
let take = (self.head_cap - self.head.len()).min(chunk.len());
self.head.extend_from_slice(&chunk[..take]);
chunk = &chunk[take..];
}
if !chunk.is_empty() {
self.tail.extend(chunk.iter().copied());
while self.tail.len() > self.tail_cap {
self.tail.pop_front();
}
}
}
pub(crate) fn finish(self) -> (String, bool) {
let truncated = self.total > self.head_cap + self.tail_cap;
let tail_bytes: Vec<u8> = self.tail.into_iter().collect();
let mut out = String::from_utf8_lossy(&self.head).into_owned();
if truncated {
let dropped = self.total - self.head.len() - tail_bytes.len();
out.push_str(&format!("\n…[output truncated, {dropped} bytes elided]…\n"));
}
out.push_str(&String::from_utf8_lossy(&tail_bytes));
(out, truncated)
}
}
pub(crate) async fn read_capped<R: AsyncRead + Unpin>(
mut reader: R,
cap: usize,
log_cap: usize,
progress: Option<tokio::sync::mpsc::Sender<ProgressEvent>>,
log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
) -> (String, bool) {
let mut buf = [0u8; 8192];
let mut capture = CappedCapture::new(cap);
let mut logged: usize = 0;
let mut log_capped = false;
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if let Some(file) = &log
&& !log_capped
{
let mut f = file.lock().await;
if logged + n <= log_cap {
let _ = f.write_all(&buf[..n]).await;
logged += n;
} else {
let remaining = log_cap - logged;
let _ = f.write_all(&buf[..remaining]).await;
let _ = f.write_all(b"\n...[log truncated]...\n").await;
log_capped = true;
}
let _ = f.flush().await;
}
if let Some(tx) = &progress {
let chunk = String::from_utf8_lossy(&buf[..n]);
for line in chunk.split('\n') {
if !line.is_empty() {
let _ = tx.send(ProgressEvent::Output(line.to_string())).await;
}
}
}
capture.push(&buf[..n]);
},
Err(_) => break,
}
}
capture.finish()
}
pub(crate) fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\u{1b}' => match chars.next() {
Some('[') => {
for f in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&f) {
break;
}
}
},
Some(']') => {
let mut prev_esc = false;
for f in chars.by_ref() {
if f == '\u{7}' || (prev_esc && f == '\\') {
break;
}
prev_esc = f == '\u{1b}';
}
},
Some('P' | 'X' | '^' | '_') => {
let mut prev_esc = false;
for f in chars.by_ref() {
if prev_esc && f == '\\' {
break;
}
prev_esc = f == '\u{1b}';
}
},
Some(_) | None => {},
},
'\u{7}' => {},
'\u{8}' => {
if out.ends_with(|p: char| p != '\n') {
out.pop();
}
},
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.push('\n');
},
_ => out.push(c),
}
}
out
}
#[expect(
clippy::too_many_lines,
reason = "predates the lint; see .github/baselines/expect_budget.txt"
)]
pub(crate) async fn run_command(
mut cmd: Command,
progress: tokio::sync::mpsc::Sender<ProgressEvent>,
token: tokio_util::sync::CancellationToken,
background: tokio_util::sync::CancellationToken,
timeout: Duration,
) -> std::io::Result<CommandRunResult> {
let mut child = cmd.spawn()?;
let pid = child.id();
let stdout = child
.stdout
.take()
.ok_or_else(|| std::io::Error::other("child stdout unavailable"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| std::io::Error::other("child stderr unavailable"))?;
let log_path = background_log_path();
let log =
create_tee_log_blocking(&log_path).map(|f| std::sync::Arc::new(tokio::sync::Mutex::new(f)));
let cap = mermaid_model::constants::MAX_TOOL_OUTPUT_BYTES;
let stdout_task = tokio::spawn(read_capped(
stdout,
cap,
TEE_LOG_CAP_BYTES,
Some(progress.clone()),
log.clone(),
));
let stderr_task = tokio::spawn(read_capped(
stderr,
cap,
TEE_LOG_CAP_BYTES,
None,
log.clone(),
));
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
let driver = tokio::spawn(async move {
let (output, _) = stdout_task.await.unwrap_or_default();
let (errors, _) = stderr_task.await.unwrap_or_default();
let status = child.wait().await;
let _ = done_tx.send((output, errors, status));
});
let timeout_fut = tokio::time::sleep(timeout);
tokio::select! {
biased;
_ = background.cancelled() => {
match pid {
Some(pid) => {
drop(driver);
Ok(CommandRunResult::Detached { pid, log_path })
}
None => {
driver.abort();
let _ = tokio::fs::remove_file(&log_path).await;
Ok(CommandRunResult::Cancelled)
}
}
}
_ = token.cancelled() => {
if let Some(p) = pid {
mermaid_model::utils::terminate_tree(p, mermaid_model::utils::Grace::Immediate).await;
}
driver.abort();
let _ = tokio::fs::remove_file(&log_path).await;
Ok(CommandRunResult::Cancelled)
}
res = done_rx => {
drop(log);
let _ = tokio::fs::remove_file(&log_path).await;
let (output, errors, status) = res
.map_err(|_| std::io::Error::other("command driver dropped before completing"))?;
let status = status?;
let stdout_lines = output.lines().count();
let stderr_lines = errors.lines().count();
let mut full_output = output;
if !errors.is_empty() {
full_output.push_str("\n--- stderr ---\n");
full_output.push_str(&errors);
}
if !status.success() {
full_output.push_str(&format!(
"\n--- Command exited with status: {} ---",
status.code().unwrap_or(-1)
));
}
#[cfg(unix)]
let signal = {
use std::os::unix::process::ExitStatusExt;
status.signal()
};
#[cfg(not(unix))]
let signal = None;
Ok(CommandRunResult::Completed(CommandRunOutput {
output: full_output,
exit_code: status.code(),
signal,
stdout_lines,
stderr_lines,
}))
}
_ = timeout_fut => {
if let Some(p) = pid {
mermaid_model::utils::terminate_tree(p, mermaid_model::utils::Grace::Immediate).await;
}
driver.abort();
let _ = tokio::fs::remove_file(&log_path).await;
Ok(CommandRunResult::TimedOut)
}
}
}