use super::*;
pub(crate) struct PtyDrain {
pub(crate) capture: CappedCapture,
pub(crate) log: Option<std::sync::Arc<tokio::sync::Mutex<tokio::fs::File>>>,
pub(crate) logged: usize,
pub(crate) log_capped: bool,
pub(crate) line_buf: String,
pub(crate) progress: tokio::sync::mpsc::Sender<ProgressEvent>,
}
impl PtyDrain {
async fn push(&mut self, chunk: &[u8]) {
if let Some(file) = &self.log
&& !self.log_capped
{
let mut f = file.lock().await;
if self.logged + chunk.len() <= TEE_LOG_CAP_BYTES {
let _ = f.write_all(chunk).await;
self.logged += chunk.len();
} else {
let remaining = TEE_LOG_CAP_BYTES - self.logged;
let _ = f.write_all(&chunk[..remaining]).await;
let _ = f.write_all(b"\n...[log truncated]...\n").await;
self.log_capped = true;
}
let _ = f.flush().await;
}
self.line_buf
.push_str(&strip_ansi(&String::from_utf8_lossy(chunk)));
while let Some(i) = self.line_buf.find('\n') {
let line: String = self.line_buf.drain(..=i).collect();
let line = line.trim_end();
if !line.is_empty() {
let _ = self
.progress
.send(ProgressEvent::Output(line.to_string()))
.await;
}
}
self.capture.push(chunk);
}
}
#[expect(
clippy::too_many_lines,
reason = "predates the lint; see .github/baselines/expect_budget.txt"
)]
pub(crate) async fn run_command_pty(
invocation: &ShellInvocation,
workdir: &Path,
scratchpad: Option<&Path>,
progress: tokio::sync::mpsc::Sender<ProgressEvent>,
token: tokio_util::sync::CancellationToken,
background: tokio_util::sync::CancellationToken,
timeout: Duration,
) -> std::io::Result<CommandRunResult> {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.map_err(std::io::Error::other)?;
let mut reader = pair
.master
.try_clone_reader()
.map_err(std::io::Error::other)?;
#[cfg(windows)]
let writer = {
use std::io::Write as _;
let mut writer = pair.master.take_writer().map_err(std::io::Error::other)?;
writer.write_all(b"\x1b[1;1R")?;
writer
};
let mut builder = CommandBuilder::new(&invocation.program);
builder.args(&invocation.args);
builder.cwd(workdir);
for name in secret_env_names() {
builder.env_remove(name);
}
builder.env("GIT_TERMINAL_PROMPT", "0");
builder.env("TERM", "xterm-256color");
if let Some(dir) = scratchpad {
builder.env(SCRATCHPAD_ENV_VAR, dir);
}
let mut child = pair
.slave
.spawn_command(builder)
.map_err(std::io::Error::other)?;
drop(pair.slave);
let pid = child.process_id();
let master = pair.master;
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 (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
let reader_thread = tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if chunk_tx.blocking_send(buf[..n].to_vec()).is_err() {
break;
}
},
}
}
});
let drain = tokio::spawn(async move {
let mut drain = PtyDrain {
capture: CappedCapture::new(mermaid_model::constants::MAX_TOOL_OUTPUT_BYTES),
log,
logged: 0,
log_capped: false,
line_buf: String::new(),
progress,
};
while let Some(chunk) = chunk_rx.recv().await {
drain.push(&chunk).await;
}
drain.capture.finish()
});
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
let driver = tokio::spawn(async move {
let status = tokio::task::spawn_blocking(move || {
let status = child.wait();
#[cfg(windows)]
drop(writer);
drop(master);
status
})
.await;
let (output, truncated) = drain.await.unwrap_or_default();
let _ = reader_thread.await;
let _ = done_tx.send((output, truncated, 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 => {
let _ = tokio::fs::remove_file(&log_path).await;
let (raw, _truncated, status) = res
.map_err(|_| std::io::Error::other("pty driver dropped before completing"))?;
let status = status
.map_err(|e| std::io::Error::other(format!("pty waiter panicked: {e}")))?
.map_err(std::io::Error::other)?;
let mut output = strip_ansi(&raw);
let (exit_code, signal) = match status.signal() {
Some(name) if name.eq_ignore_ascii_case("bad system call") => {
(None, Some(SANDBOX_KILL_SIGNAL))
},
Some(_) => (None, None),
None => (Some(status.exit_code() as i32), None),
};
if !status.success() {
output.push_str(&format!(
"\n--- Command exited with status: {} ---",
exit_code.unwrap_or(-1)
));
}
let stdout_lines = output.lines().count();
Ok(CommandRunResult::Completed(CommandRunOutput {
output,
exit_code,
signal,
stdout_lines,
stderr_lines: 0,
}))
}
_ = 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)
}
}
}
pub(crate) fn contains_dangerous_command(command: &str) -> bool {
mermaid_runtime::is_destructive_command(command)
}