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
);
}
}