use crate::tools::types::{
Tool, ToolContext, ToolErrorKind, ToolEventSender, ToolOutput, ToolStreamEvent,
};
use crate::workspace::{CommandOutputObserver, CommandOutputSummary, CommandRequest};
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use tokio::process::Command;
#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub(crate) use windows::maybe_execute_simple_windows_http_command;
#[cfg(windows)]
use windows::{build_powershell_command, encode_powershell_command, CREATE_NO_WINDOW};
#[cfg(all(test, windows))]
use windows::{
normalize_json_like_literal, parse_simple_windows_http_command, preprocess_windows_command,
};
pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MIN_TIMEOUT_MS: u64 = 1_000;
struct ToolEventObserver {
tx: Option<ToolEventSender>,
summary: Mutex<Option<CommandOutputSummary>>,
}
#[async_trait]
impl CommandOutputObserver for ToolEventObserver {
async fn on_output_delta(&self, delta: &str) {
if let Some(tx) = &self.tx {
tx.send(ToolStreamEvent::OutputDelta(delta.to_string()))
.await
.ok();
}
}
async fn on_output_complete(&self, summary: &CommandOutputSummary) {
*self.summary.lock().unwrap() = Some(*summary);
}
}
pub struct BashTool;
pub(crate) fn spawn_shell(
command: &str,
workspace: &std::path::Path,
command_env: Option<&HashMap<String, String>>,
) -> std::io::Result<tokio::process::Child> {
#[cfg(windows)]
{
fn prepare_command(
cmd: &mut Command,
workspace: &std::path::Path,
command_env: Option<&HashMap<String, String>>,
) {
cmd.current_dir(workspace)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.creation_flags(CREATE_NO_WINDOW);
if let Some(env) = command_env {
cmd.envs(env);
}
}
let wrapped_command = build_powershell_command(command);
let encoded_command = encode_powershell_command(&wrapped_command);
let mut powershell = Command::new("powershell.exe");
powershell.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded_command,
]);
prepare_command(&mut powershell, workspace, command_env);
match powershell.spawn() {
Ok(child) => Ok(child),
Err(ps_error) => {
let mut cmd = Command::new("cmd.exe");
cmd.args(["/C", command]);
prepare_command(&mut cmd, workspace, command_env);
cmd.spawn().map_err(|cmd_error| {
std::io::Error::new(
cmd_error.kind(),
format!(
"failed to spawn powershell.exe ({ps_error}); fallback cmd.exe also failed: {cmd_error}"
),
)
})
}
}
}
#[cfg(not(windows))]
{
let mut cmd = Command::new("bash");
cmd.arg("-c")
.arg(command)
.current_dir(workspace)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.as_std_mut().process_group(0);
}
if let Some(env) = command_env {
cmd.envs(env);
}
cmd.spawn()
}
}
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Execute a shell command in the workspace directory. On Windows this runs in a hidden PowerShell session, not GNU bash. Use for running commands, installing packages, and running tests."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"command": {
"type": "string",
"description": "Required. The exact shell command to execute. Always provide this exact field name: 'command'. On Windows the command must be PowerShell-compatible; the tool provides a small compatibility shim for curl, wget, bare HTTP verbs (GET/POST/PUT/PATCH/DELETE/OPTIONS), which, and head."
},
"timeout": {
"type": "integer",
"description": "Optional. Timeout in milliseconds. Default: 120000. Values below 1000 are clamped to 1000 to avoid accidental immediate timeouts."
}
},
"required": ["command"],
"examples": [
{
"command": "cargo test -p a3s-code-core skill::"
},
{
"command": "npm test",
"timeout": 300000
}
]
})
}
async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
let command = match args.get("command").and_then(|v| v.as_str()) {
Some(c) => c,
None => return Ok(ToolOutput::error("command parameter is required")),
};
if let Some(ref sandbox) = ctx.sandbox {
let result = sandbox
.exec_command(command, "/workspace")
.await
.map_err(|e| anyhow::anyhow!("Sandbox bash execution failed: {}", e))?;
let mut output = result.stdout;
if !result.stderr.is_empty() {
output.push_str(&result.stderr);
}
return Ok(ToolOutput {
content: output,
success: result.exit_code == 0,
metadata: Some(serde_json::json!({ "exit_code": result.exit_code })),
images: vec![],
error_kind: None,
});
}
let runner = ctx
.workspace_services
.command_runner()
.expect("bash registered without workspace command runner");
let requested_timeout_ms = args
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_MS);
let timeout_ms = requested_timeout_ms.max(MIN_TIMEOUT_MS);
let event_observer = Arc::new(ToolEventObserver {
tx: ctx.event_tx.clone(),
summary: Mutex::new(None),
});
let output_observer = Some(Arc::clone(&event_observer) as Arc<dyn CommandOutputObserver>);
let result = runner
.exec(CommandRequest {
command: command.to_string(),
timeout_ms,
output_observer,
env: ctx.command_env.clone(),
})
.await
.map_err(|e| anyhow::anyhow!("Workspace bash execution failed: {}", e))?;
let capture_summary = *event_observer.summary.lock().unwrap();
let capture_metadata = capture_summary.map(|summary| {
serde_json::json!({
"total_bytes": summary.total_bytes,
"captured_bytes": summary.captured_bytes,
"truncated": summary.truncated,
"timed_out": summary.timed_out,
})
});
if result.timed_out {
let mut output = ToolOutput::error(format!(
"{}\n\n[Command timed out after {}ms]",
result.output, timeout_ms
));
output.metadata = Some(serde_json::json!({
"exit_code": result.exit_code,
"timeout_ms": timeout_ms,
"output": capture_metadata,
}));
output.error_kind = Some(ToolErrorKind::Timeout {
op: "bash".to_string(),
duration_ms: timeout_ms,
});
return Ok(output);
}
Ok(ToolOutput {
content: result.output,
success: result.exit_code == 0,
metadata: Some(serde_json::json!({
"exit_code": result.exit_code,
"output": capture_metadata,
})),
images: vec![],
error_kind: None,
})
}
}
#[cfg(test)]
#[path = "bash/tests.rs"]
mod tests;