A git wrapper (docker/git-coauthor -> /usr/local/lib/codecraft/bin/git) adds
--trailer "$CODECRAFT_COMMIT_TRAILER" right after the commit subcommand; the
agent shell puts it first on PATH and sets the trailer in the script itself
(works inside the sandbox, which clears the environment). Default text:
"Co-Authored-By: {model} ({provider}) via CodeCraft" (server.commit_trailer,
Settings -> General; empty = off). Git skips duplicates (amend, model wrote it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.2 KiB
Rust
219 lines
9.2 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}", commit_attribution(ctx));
|
||
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
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Directory with the git wrapper that adds the co-author trailer to commits.
|
||
fn git_wrapper_dir() -> String {
|
||
std::env::var("CODECRAFT_GIT_WRAPPER").unwrap_or_else(|_| "/usr/local/lib/codecraft/bin".into())
|
||
}
|
||
|
||
/// Shell lines that make `git commit` in this command carry the co-author trailer of
|
||
/// the session's model (see docker/git-coauthor). Set in the script itself, so it
|
||
/// works inside the sandbox (which clears the environment) too.
|
||
fn commit_attribution(ctx: &ToolCtx) -> String {
|
||
let template = ctx.app.config().server.commit_trailer.clone();
|
||
let dir = git_wrapper_dir();
|
||
if template.trim().is_empty() || !std::path::Path::new(&dir).join("git").exists() {
|
||
return String::new();
|
||
}
|
||
let Ok(session) = ctx.app.session(&ctx.session_id) else { return String::new() };
|
||
let (provider, model_id) = session.model.split_once('/').unwrap_or(("", session.model.as_str()));
|
||
let name = ctx.app.registry().resolve(&session.model).ok().and_then(|(_, m)| m.name).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| model_id.to_string());
|
||
let trailer = commit_trailer(&template, &name, provider, model_id);
|
||
format!("export PATH={}:\"$PATH\" CODECRAFT_COMMIT_TRAILER={}\n", shell_quote(&dir), shell_quote(&trailer))
|
||
}
|
||
|
||
/// The trailer text for a model ("Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft").
|
||
pub fn commit_trailer(template: &str, model: &str, provider: &str, model_id: &str) -> String {
|
||
template.replace("{model}", model).replace("{provider}", provider).replace("{model_id}", model_id).replace(['\n', '\r'], " ").trim().to_string()
|
||
}
|
||
|
||
fn shell_quote(s: &str) -> String {
|
||
format!("'{}'", s.replace('\'', "'\\''"))
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod commit_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn commit_trailer_text() {
|
||
assert_eq!(
|
||
commit_trailer(crate::config::DEFAULT_COMMIT_TRAILER, "DeepSeek V4.1 Flash", "opencode-go", "deepseek-v4.1-flash"),
|
||
"Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft"
|
||
);
|
||
assert_eq!(commit_trailer("By {model_id}\nx", "M", "p", "m-1"), "By m-1 x");
|
||
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||
}
|
||
}
|