Lets subagents report context files back to the job board (surfaced from the read tool) so the parent session's next-turn context includes what a background child has been looking at.
218 lines
6.8 KiB
Rust
218 lines
6.8 KiB
Rust
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
|
|
const DEFAULT_TIMEOUT_MS: u64 = 2 * 60 * 1000;
|
|
const MAX_TIMEOUT_MS: u64 = 10 * 60 * 1000;
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
struct BashParams {
|
|
command: String,
|
|
timeout_ms: Option<u64>,
|
|
cwd: Option<String>,
|
|
#[allow(dead_code)]
|
|
description: String,
|
|
}
|
|
|
|
pub struct BashTool;
|
|
|
|
/// The "always allow" pattern is coarser than the exact command: opencode/docs/05-tools.md
|
|
/// grants `<first word> *` (e.g. `git *`), not the literal command string.
|
|
fn always_pattern(command: &str) -> String {
|
|
shell_words::split(command)
|
|
.ok()
|
|
.and_then(|words| words.into_iter().next())
|
|
.map(|first| format!("{first} *"))
|
|
.unwrap_or_else(|| "*".to_string())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[allow(unsafe_code)]
|
|
fn kill_group(pid: u32) {
|
|
// Negative pid signals the whole process group (spawned with `process_group(0)`).
|
|
// Safety: `kill` is a plain libc syscall; passing a negative pid targets the group,
|
|
// which is exactly the process tree we spawned and want to tear down.
|
|
unsafe {
|
|
libc::kill(-(pid as i32), libc::SIGKILL);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn kill_group(_pid: u32) {}
|
|
|
|
#[async_trait]
|
|
impl Tool for BashTool {
|
|
fn name(&self) -> &str {
|
|
"bash"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Runs a shell command and returns its combined stdout/stderr."
|
|
}
|
|
|
|
fn parameters(&self) -> serde_json::Value {
|
|
serde_json::to_value(schemars::schema_for!(BashParams)).unwrap()
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
input: serde_json::Value,
|
|
ctx: ToolCtx,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let params: BashParams =
|
|
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
|
|
let timeout_ms = params
|
|
.timeout_ms
|
|
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
|
.min(MAX_TIMEOUT_MS);
|
|
|
|
ctx.ask
|
|
.ask(
|
|
"bash",
|
|
params.command.clone(),
|
|
always_pattern(¶ms.command),
|
|
serde_json::json!({"command": params.command}),
|
|
)
|
|
.await?;
|
|
|
|
let cwd = params
|
|
.cwd
|
|
.as_ref()
|
|
.map(|c| crate::paths::resolve(&ctx.cwd, c))
|
|
.unwrap_or_else(|| ctx.cwd.clone());
|
|
|
|
let mut cmd = tokio::process::Command::new("sh");
|
|
cmd.arg("-c")
|
|
.arg(¶ms.command)
|
|
.current_dir(&cwd)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
#[cfg(unix)]
|
|
cmd.process_group(0);
|
|
|
|
let child = cmd
|
|
.spawn()
|
|
.map_err(|e| ToolError::Other(format!("spawn failed: {e}")))?;
|
|
let pid = child.id();
|
|
|
|
tokio::select! {
|
|
result = child.wait_with_output() => {
|
|
let output = result.map_err(|e| ToolError::Other(format!("wait failed: {e}")))?;
|
|
let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
|
|
combined.push_str(&String::from_utf8_lossy(&output.stderr));
|
|
let title = format!("{} (exit {})", params.command, output.status.code().unwrap_or(-1));
|
|
Ok(ToolOutput::new(title, combined))
|
|
}
|
|
_ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => {
|
|
if let Some(pid) = pid { kill_group(pid); }
|
|
Err(ToolError::Other(format!("command timed out after {timeout_ms}ms")))
|
|
}
|
|
_ = ctx.cancel.cancelled() => {
|
|
if let Some(pid) = pid { kill_group(pid); }
|
|
Err(ToolError::Cancelled)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use harness_core::event::EventBus;
|
|
use harness_core::permission::{spawn_auto_approve, PermissionService};
|
|
use harness_core::tool::{MetadataSink, PermissionHandle};
|
|
use harness_core::types::SessionId;
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
fn ctx(cwd: std::path::PathBuf) -> ToolCtx {
|
|
let bus = EventBus::new();
|
|
let service = Arc::new(PermissionService::new(bus.clone()));
|
|
spawn_auto_approve(bus, service.clone());
|
|
let (metadata, _rx) = MetadataSink::channel();
|
|
ToolCtx {
|
|
session_id: SessionId::new(),
|
|
message_id: harness_core::types::MessageId::new(),
|
|
call_id: "call_1".into(),
|
|
data_dir: cwd.join("tool-output"),
|
|
cwd,
|
|
cancel: CancellationToken::new(),
|
|
ask: PermissionHandle::new(
|
|
service,
|
|
SessionId::new(),
|
|
Vec::new(),
|
|
Arc::new(Mutex::new(Vec::new())),
|
|
CancellationToken::new(),
|
|
),
|
|
metadata,
|
|
spawner: None,
|
|
context_reporter: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn always_pattern_uses_first_word() {
|
|
assert_eq!(always_pattern("git push origin main"), "git *");
|
|
assert_eq!(always_pattern("ls -la"), "ls *");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runs_command_and_captures_stdout() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let output = BashTool
|
|
.execute(
|
|
serde_json::json!({"command": "echo hello", "description": "say hi"}),
|
|
ctx(dir.path().to_path_buf()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(output.output.contains("hello"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn captures_stderr_too() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let output = BashTool
|
|
.execute(
|
|
serde_json::json!({"command": "echo err 1>&2", "description": "stderr"}),
|
|
ctx(dir.path().to_path_buf()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(output.output.contains("err"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn times_out_long_running_commands() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let err = BashTool
|
|
.execute(
|
|
serde_json::json!({"command": "sleep 5", "timeout_ms": 50, "description": "slow"}),
|
|
ctx(dir.path().to_path_buf()),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, ToolError::Other(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn respects_cwd_override() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let output = BashTool
|
|
.execute(
|
|
serde_json::json!({"command": "pwd", "description": "where"}),
|
|
ctx(dir.path().to_path_buf()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(output
|
|
.output
|
|
.trim()
|
|
.ends_with(dir.path().file_name().unwrap().to_str().unwrap()));
|
|
}
|
|
}
|