Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,140 @@
|
||||
//! 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."
|
||||
.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"}
|
||||
},
|
||||
"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 = Command::new("bash");
|
||||
cmd.arg("-c")
|
||||
.arg(format!("{command} 2>&1"))
|
||||
.current_dir(&ctx.cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.env("CODECRAFT", "1")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("PAGER", "cat")
|
||||
.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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user