- 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>
171 lines
6.9 KiB
Rust
171 lines
6.9 KiB
Rust
//! 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()
|
||
}
|