Files
darman ac516a7e23 M4: subagent context-file reporting to the job board
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.
2026-07-10 16:20:11 +02:00

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(&params.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(&params.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()));
}
}