claux 20260730.0.0

Terminal AI coding assistant with tool execution
use anyhow::Result;
use async_trait::async_trait;
#[cfg(windows)]
use process_wrap::tokio::JobObject;
#[cfg(unix)]
use process_wrap::tokio::ProcessGroup;
use process_wrap::tokio::{CommandWrap, KillOnDrop};
use serde::Deserialize;
use serde_json::{json, Value};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio_util::sync::CancellationToken;

use super::{Tool, ToolOutput};
use crate::command_sandbox::CommandSandbox;

pub struct BashTool {
    sandbox: Arc<CommandSandbox>,
}

impl BashTool {
    pub fn new(sandbox: Arc<CommandSandbox>) -> Self {
        Self { sandbox }
    }
}

#[derive(Deserialize)]
struct Params {
    command: String,
    #[serde(default)]
    timeout: Option<u64>,
    #[serde(default)]
    #[serde(rename = "description")]
    _description: Option<String>,
}

#[async_trait]
impl Tool for BashTool {
    fn name(&self) -> &str {
        "Bash"
    }

    fn description(&self) -> &str {
        "Execute a bash command. Use for git, build tools, or other CLI operations."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "The bash command to execute"
                },
                "timeout": {
                    "type": "integer",
                    "description": "Timeout in milliseconds (max 600000, default 120000)"
                },
                "description": {
                    "type": "string",
                    "description": "Short description of what the command does"
                }
            },
            "required": ["command"]
        })
    }

    fn is_read_only(&self) -> bool {
        false // conservative default; could be smarter with command analysis
    }

    fn summarize(&self, input: &Value) -> String {
        let cmd = input["command"].as_str().unwrap_or("?");
        // Truncate long commands
        if cmd.len() > 80 {
            format!("{}...", crate::utils::truncate_str(cmd, 77))
        } else {
            cmd.to_string()
        }
    }

    async fn execute(&self, input: Value, cancel: CancellationToken) -> Result<ToolOutput> {
        let params: Params = serde_json::from_value(input)?;

        let timeout_ms = params.timeout.unwrap_or(120_000).min(600_000);
        let timeout = Duration::from_millis(timeout_ms);

        let mut inner = self.sandbox.command(&params.command)?;
        inner.stdout(Stdio::piped()).stderr(Stdio::piped());
        let mut command = CommandWrap::from(inner);
        // Commands commonly create descendants (shell pipelines, test runners,
        // build systems). Put the whole tree in one killable unit so cancelling
        // the tool cannot leave grandchildren alive with our pipes still open.
        command.wrap(KillOnDrop);
        #[cfg(unix)]
        command.wrap(ProcessGroup::leader());
        #[cfg(windows)]
        command.wrap(JobObject);

        let mut child = match command.spawn() {
            Ok(c) => c,
            Err(e) => {
                return Ok(ToolOutput {
                    content: format!("Failed to execute command: {e}"),
                    is_error: true,
                });
            }
        };

        // Take the pipes so we can read them concurrently with wait().
        let mut stdout_pipe = child.stdout().take();
        let mut stderr_pipe = child.stderr().take();

        // Spawn readers so partial output is captured even if we get cancelled
        // or time out mid-stream.
        let stdout_task = tokio::spawn(async move {
            let mut buf = Vec::new();
            if let Some(p) = stdout_pipe.as_mut() {
                let _ = p.read_to_end(&mut buf).await;
            }
            buf
        });
        let stderr_task = tokio::spawn(async move {
            let mut buf = Vec::new();
            if let Some(p) = stderr_pipe.as_mut() {
                let _ = p.read_to_end(&mut buf).await;
            }
            buf
        });

        let outcome = tokio::select! {
            status = child.wait() => Outcome::Finished(status),
            _ = cancel.cancelled() => Outcome::Cancelled,
            _ = tokio::time::sleep(timeout) => Outcome::TimedOut,
        };

        // For Cancelled / TimedOut, the child is still alive — kill it.
        if !matches!(outcome, Outcome::Finished(_)) {
            let _ = Box::into_pin(child.kill()).await;
        }

        let stdout = stdout_task.await.unwrap_or_default();
        let stderr = stderr_task.await.unwrap_or_default();

        let stdout_s = render_output(&stdout, "stdout");
        let stderr_s = render_output(&stderr, "stderr");

        let mut content = String::new();
        if !stdout_s.is_empty() {
            content.push_str(&stdout_s);
        }
        if !stderr_s.is_empty() {
            if !content.is_empty() {
                content.push('\n');
            }
            content.push_str(&stderr_s);
        }

        let is_error = match &outcome {
            Outcome::Finished(Ok(status)) => {
                if !status.success() {
                    content.push_str(&format!("\nExit code: {status}"));
                }
                !status.success()
            }
            Outcome::Finished(Err(e)) => {
                if !content.is_empty() {
                    content.push('\n');
                }
                content.push_str(&format!("wait error: {e}"));
                true
            }
            Outcome::Cancelled => {
                if !content.is_empty() {
                    content.push('\n');
                }
                content.push_str("Interrupted by user.");
                true
            }
            Outcome::TimedOut => {
                if !content.is_empty() {
                    content.push('\n');
                }
                content.push_str(&format!("Command timed out after {timeout_ms}ms"));
                true
            }
        };

        if content.len() > 100_000 {
            content.truncate(100_000);
            content.push_str("\n... (output truncated)");
        }

        Ok(ToolOutput { content, is_error })
    }
}

fn render_output(bytes: &[u8], stream: &str) -> String {
    match std::str::from_utf8(bytes) {
        Ok(text) if !bytes.contains(&0) => text.to_string(),
        _ => format!("[binary {stream} suppressed: {} bytes]", bytes.len()),
    }
}

enum Outcome {
    Finished(std::io::Result<std::process::ExitStatus>),
    Cancelled,
    TimedOut,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn token() -> CancellationToken {
        CancellationToken::new()
    }

    fn tool() -> BashTool {
        BashTool::new(Arc::new(CommandSandbox::unrestricted_for_tests()))
    }

    #[tokio::test]
    async fn bash_echo() {
        let tool = tool();
        let result = tool
            .execute(json!({"command": "echo hello"}), token())
            .await
            .unwrap();
        assert!(!result.is_error);
        assert!(result.content.trim().contains("hello"));
    }

    #[tokio::test]
    async fn bash_exit_code() {
        let tool = tool();
        let result = tool
            .execute(json!({"command": "exit 1"}), token())
            .await
            .unwrap();
        assert!(result.is_error);
        assert!(result.content.contains("Exit code"));
    }

    #[tokio::test]
    async fn bash_captures_stderr() {
        let tool = tool();
        let result = tool
            .execute(json!({"command": "echo err >&2"}), token())
            .await
            .unwrap();
        assert!(result.content.contains("err"));
    }

    #[test]
    fn preserves_utf8_output() {
        assert_eq!(render_output("héllo\n".as_bytes(), "stdout"), "héllo\n");
    }

    #[test]
    fn suppresses_invalid_utf8_output() {
        assert_eq!(
            render_output(&[0xff, 0xfe, 0xfd], "stdout"),
            "[binary stdout suppressed: 3 bytes]"
        );
    }

    #[test]
    fn suppresses_nul_containing_output() {
        assert_eq!(
            render_output(b"text\0more", "stderr"),
            "[binary stderr suppressed: 9 bytes]"
        );
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn binary_stdout_does_not_hide_text_stderr() {
        let result = tool()
            .execute(
                json!({"command": "printf '\\377'; printf 'warning' >&2"}),
                token(),
            )
            .await
            .unwrap();

        assert!(!result.is_error);
        assert_eq!(
            result.content,
            "[binary stdout suppressed: 1 bytes]\nwarning"
        );
        assert!(!result.content.contains('\u{fffd}'));
    }

    #[tokio::test]
    async fn bash_timeout() {
        let tool = tool();
        let result = tool
            .execute(json!({"command": "sleep 10", "timeout": 100}), token())
            .await
            .unwrap();
        assert!(result.is_error);
        assert!(result.content.contains("timed out"));
    }

    #[tokio::test]
    async fn bash_cancellation() {
        let tool = tool();
        let cancel = CancellationToken::new();
        let cancel_clone = cancel.clone();
        tokio::spawn(async move {
            tokio::time::sleep(Duration::from_millis(100)).await;
            cancel_clone.cancel();
        });
        let start = std::time::Instant::now();
        let result = tool
            .execute(
                json!({
                    "command": "trap '' HUP; sleep 30 & wait",
                    "timeout": 60000
                }),
                cancel,
            )
            .await
            .unwrap();
        assert!(
            start.elapsed() < Duration::from_secs(3),
            "cancellation should kill the entire process tree (took {:?})",
            start.elapsed()
        );
        assert!(result.is_error);
        assert!(result.content.contains("Interrupted"));
    }
}