magi-code 0.64.0

Repository-aware CLI coding agent for terminal work
Documentation
use super::{
    ToolResult, ToolResultDisplay, ToolRuntime,
    args::{BASH_STDERR_MAX_BYTES, BASH_STDOUT_MAX_BYTES, BashArgs},
    contract::{metadata_key as meta, tool_name},
    process::{BoundedChildProcessLimits, run_bounded_child_process},
};
use crate::{
    agent::cancellation::AgentCancellation,
    shell::runtime::{self, ShellEnvPolicy, ShellStdin},
};
use serde_json::json;
use std::time::Duration;

impl ToolRuntime {
    pub(super) fn bash(
        &self,
        args: BashArgs,
        cancellation: &AgentCancellation,
    ) -> anyhow::Result<ToolResult> {
        args.validate()?;
        let command = args
            .effective_command()
            .expect("validated command")
            .to_string();
        runtime::preflight_bash_cwd_scope(
            &command,
            self.bash_absolute_paths,
            self.bash_shell_expansion,
        )?;
        let timeout = Duration::from_secs(args.timeout.unwrap_or(30));
        let child = runtime::spawn_platform_shell(
            &command,
            &self.cwd,
            ShellStdin::Null,
            ShellEnvPolicy::Ambient,
        )?;
        let output = run_bounded_child_process(
            child,
            BoundedChildProcessLimits {
                stdout_max_bytes: BASH_STDOUT_MAX_BYTES,
                stderr_max_bytes: BASH_STDERR_MAX_BYTES,
                timeout,
                poll_interval: Duration::from_millis(20),
            },
            cancellation,
        )?;
        let stdout = output.stdout;
        let stderr = output.stderr;
        let cleanup_warning = output.cleanup_warning;
        let stdout_truncated = output.stdout_truncated;
        let stderr_truncated = output.stderr_truncated;
        let exit_code = if output.timed_out {
            None
        } else {
            output.status.as_ref().and_then(|status| status.code())
        };
        let mut content = format!("stdout:\n{stdout}\nstderr:\n{stderr}");
        if stdout_truncated || stderr_truncated {
            content.push_str(&format!(
                "\n[output truncated: stdout limit {BASH_STDOUT_MAX_BYTES} bytes, stderr limit {BASH_STDERR_MAX_BYTES} bytes]"
            ));
        }
        if let Some(cleanup_warning) = &cleanup_warning {
            content.push_str("\n[");
            content.push_str(cleanup_warning);
            content.push(']');
        }
        Ok(ToolResult {
            tool_name: tool_name::BASH.to_string(),
            success: output
                .status
                .as_ref()
                .is_some_and(|status| status.success())
                && !output.timed_out
                && !stdout_truncated
                && !stderr_truncated
                && cleanup_warning.is_none(),
            content,
            metadata: json!({(meta::EXIT_CODE): exit_code, (meta::TIMED_OUT): output.timed_out, (meta::STDOUT): stdout, (meta::STDERR): stderr, (meta::STDOUT_TRUNCATED): stdout_truncated, (meta::STDERR_TRUNCATED): stderr_truncated, (meta::STDOUT_LIMIT_BYTES): BASH_STDOUT_MAX_BYTES, (meta::STDERR_LIMIT_BYTES): BASH_STDERR_MAX_BYTES, (meta::CLEANUP_WARNING): cleanup_warning}),
            display: ToolResultDisplay::default(),
        })
    }
}

#[cfg(test)]
mod tests {
    use crate::{
        agent::cancellation::AgentCancellation,
        output::ToolDispatchContext,
        tools::{BashToolSettings, ToolRuntime, ToolSettings},
    };
    use std::{
        sync::{Arc, atomic::AtomicBool},
        thread,
        time::{Duration, Instant},
    };

    #[test]
    fn bash_executes_commands_chained_with_logical_and() {
        let temp = tempfile::TempDir::new().unwrap();
        let runtime = ToolRuntime::new_with_settings(temp.path(), ToolSettings::default()).unwrap();

        let result = runtime.dispatch(
            "bash",
            serde_json::json!({"command":"printf left && printf right"}),
        );

        assert!(result.success, "{}", result.content);
        assert!(
            result.content.contains("stdout:\nleftright"),
            "{}",
            result.content
        );
    }

    #[test]
    fn bash_allows_shell_expansion_by_default() {
        let temp = tempfile::TempDir::new().unwrap();
        let runtime = ToolRuntime::new_with_settings(temp.path(), ToolSettings::default()).unwrap();

        let result = runtime.dispatch("bash", serde_json::json!({"command":"echo $PWD"}));

        assert!(result.success, "{}", result.content);
    }

    #[test]
    fn bash_rejects_shell_expansion_when_disabled() {
        let temp = tempfile::TempDir::new().unwrap();
        let runtime = ToolRuntime::new_with_settings(
            temp.path(),
            ToolSettings {
                bash: BashToolSettings {
                    shell_expansion: false,
                    ..BashToolSettings::default()
                },
                ..ToolSettings::default()
            },
        )
        .unwrap();

        let result = runtime.dispatch("bash", serde_json::json!({"command":"echo $PWD"}));

        assert!(!result.success);
        assert!(
            result.content.contains("tools.bash.shell_expansion"),
            "{}",
            result.content
        );
    }

    #[test]
    fn bash_cancellation_stops_running_process_promptly() {
        let temp = tempfile::TempDir::new().unwrap();
        let runtime = ToolRuntime::new(temp.path()).unwrap();
        let cancel = Arc::new(AtomicBool::new(false));
        let mut context = ToolDispatchContext::new(None, None);
        context.cancellation = AgentCancellation::new(Arc::clone(&cancel));
        let started = Instant::now();
        let cancel_handle = Arc::clone(&cancel);
        let canceller = thread::spawn(move || {
            thread::sleep(Duration::from_millis(50));
            cancel_handle.store(true, std::sync::atomic::Ordering::SeqCst);
        });

        let result = runtime.dispatch_with_context(
            "bash",
            serde_json::json!({"command":"sleep 2", "timeout":10}),
            context,
        );
        canceller.join().unwrap();

        assert!(!result.success);
        assert!(
            result.content.contains("prompt canceled"),
            "{}",
            result.content
        );
        assert!(started.elapsed() < Duration::from_secs(1));
    }

    #[test]
    fn bash_shared_preflight_uses_absolute_path_setting() {
        let temp = tempfile::TempDir::new().unwrap();
        let runtime = ToolRuntime::new_with_settings(
            temp.path(),
            ToolSettings {
                bash: BashToolSettings {
                    absolute_paths: false,
                    ..BashToolSettings::default()
                },
                ..ToolSettings::default()
            },
        )
        .unwrap();

        let result = runtime.dispatch(
            "bash",
            serde_json::json!({"command":"cat /tmp/magi-code-marker"}),
        );
        assert!(!result.success);
        assert!(
            result.content.contains("tools.bash.absolute_paths"),
            "{}",
            result.content
        );
    }
}