Files
codecraft/server/src/tools/bash.rs
T
Richard ZinkandClaude Opus 5.5 df1924b0a1 Add web terminal, git credentials, OpenSCAD, review loops, automations
- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay)
- Per-user git credentials and commit identity (credential store in the user's home)
- OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card
- Craft phase inferred from tool activity when the agent does not report it
- Configurable review loop per mode (rounds, reviewer model, criteria)
- Delete config and Markdown agents from the agent editor
- Automations: cron-scheduled agents from Markdown job files, schedule tool, page
- change_directory tool (only on the user's request)

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

171 lines
6.9 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(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
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()
}