Files
codecraft/server/src/tools/bash.rs
T
Richard ZinkandClaude Opus 5.5 0f4bfa0ab0 Git identity from the settings for all agent shells, terminals and the git panel
Set GIT_AUTHOR_*/GIT_COMMITTER_* from Settings → Account → Git (also inside the
sandbox), so a repository's own user.name/user.email no longer wins; tell agents
not to change the git identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:22:25 +01:00

174 lines
7.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shell command execution with timeout, cancellation and live output.
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Value, json};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
const MAX_OUTPUT: usize = 30_000;
pub struct Bash;
#[async_trait]
impl Tool for Bash {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> String {
"Run a shell command (bash) in the session working directory. stdout and stderr are \
combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \
does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \
dedicated read/edit/glob/grep tools over cat/sed/find/grep. For long-running processes \
(dev servers, watchers, long builds) set run_in_background=true: you get a shell id, \
read new output with bash_output and stop it with kill_shell."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {"type": "string"},
"description": {"type": "string", "description": "5-10 word description of what the command does"},
"timeout_ms": {"type": "integer"},
"run_in_background": {"type": "boolean", "description": "Keep running; read output with bash_output"}
},
"required": ["command"]
})
}
fn title(&self, input: &Value) -> String {
input["description"]
.as_str()
.map(String::from)
.unwrap_or_else(|| format!("Run: {}", input["command"].as_str().unwrap_or("?")))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let command = arg_str(&input, "command")?;
let timeout = Duration::from_millis(
input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS),
);
let mut cmd = match shell_command(ctx, command) {
Ok(c) => c,
Err(e) => return Ok(e),
};
if input["run_in_background"].as_bool().unwrap_or(false) {
let id = crate::shells::spawn(&ctx.app, &ctx.session_id, command, cmd)?;
return Ok(ToolOutput::text(format!(
"Started in the background with shell id {id}. Read its output with bash_output (shell_id \"{id}\") and stop it with kill_shell."
))
.with_meta(json!({"shell_id": id})));
}
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
// Own process group so the whole tree can be killed on abort/timeout.
cmd.process_group(0);
let mut child = cmd.spawn()?;
let pid = child.id();
let stdout = child.stdout.take().unwrap();
// split() instead of lines(): non-UTF-8 output must not end the read loop.
let mut lines = BufReader::new(stdout).split(b'\n');
let mut output = String::new();
let started = Instant::now();
let mut last_progress = Instant::now();
let deadline = tokio::time::sleep(timeout);
tokio::pin!(deadline);
let kill_group = |pid: Option<u32>| {
if let Some(pid) = pid {
// SAFETY: plain syscall; negative pid targets the process group.
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
};
let status = loop {
tokio::select! {
line = lines.next_segment() => match line {
Ok(Some(l)) => {
if output.len() < MAX_OUTPUT * 4 {
output.push_str(&String::from_utf8_lossy(&l));
output.push('\n');
}
if last_progress.elapsed() > Duration::from_millis(300) {
ctx.progress(tail(&output, 4000));
last_progress = Instant::now();
}
}
Ok(None) | Err(_) => break child.wait().await?,
},
_ = &mut deadline => {
kill_group(pid);
let _ = child.wait().await;
output.push_str(&format!("\n[command timed out after {}s and was killed]", timeout.as_secs()));
return Ok(ToolOutput::error(truncate(&output, MAX_OUTPUT)));
}
_ = ctx.cancel.cancelled() => {
kill_group(pid);
let _ = child.wait().await;
return Ok(ToolOutput::error("[command aborted by user]"));
}
}
};
let code = status.code().unwrap_or(-1);
let mut text = truncate(output.trim_end(), MAX_OUTPUT);
if text.is_empty() {
text = "(no output)".into();
}
if code != 0 {
text.push_str(&format!("\n[exit code {code}]"));
}
Ok(ToolOutput {
content: text,
is_error: code != 0,
meta: Some(json!({"exit_code": code, "duration_ms": started.elapsed().as_millis() as u64})),
..Default::default()
})
}
}
/// The bash command for a script, sandboxed for non-admin users.
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
// stderr of every command (not just the last one) goes into the output.
let script = format!("exec 2>&1\n{command}");
Ok(match &ctx.sandbox {
Some(spec) => {
if !crate::sandbox::available() {
return Err(ToolOutput::error(
"Shell commands are disabled for this account: the server's sandbox (bubblewrap) is not available. Ask an admin.",
));
}
crate::sandbox::command(spec, &ctx.cwd, "bash", &["-c", &script])
}
None => {
let mut c = Command::new("bash");
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
// The session owner's git credentials and identity.
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
if let Some(u) = owner {
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
}
c
}
})
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut start = s.len() - max;
while !s.is_char_boundary(start) {
start += 1;
}
s[start..].to_string()
}