harness-bash 0.1.1

Bash tool for AI agent harnesses — autonomous shell execution with tokio process management, cwd-carry, inactivity timeout, head+tail spill, background jobs
Documentation
//! `harness-bash-cli` — JSON-RPC bridge. Methods: bash, bash_output, bash_kill.

use harness_bash::{
    bash, bash_kill, bash_output, default_executor, BashPermissionPolicy,
    BashSessionConfig,
};
use harness_core::{PermissionPolicy, ToolError, ToolErrorCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

#[derive(Debug, Deserialize)]
struct Request {
    #[serde(default)]
    id: Value,
    method: String,
    #[serde(default)]
    params: Value,
}

#[derive(Debug, Serialize)]
struct Response {
    id: Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    result: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<RpcError>,
}

#[derive(Debug, Serialize)]
struct RpcError {
    code: i32,
    message: String,
}

#[derive(Debug, Deserialize)]
struct Call {
    params: Value,
    session: SessionSpec,
}

#[derive(Debug, Deserialize)]
struct SessionSpec {
    cwd: String,
    #[serde(default)]
    roots: Vec<String>,
    #[serde(default)]
    sensitive_patterns: Vec<String>,
    #[serde(default)]
    bypass_workspace_guard: bool,
    #[serde(default)]
    unsafe_allow_bash_without_hook: bool,
    #[serde(default)]
    default_inactivity_timeout_ms: Option<u64>,
    #[serde(default)]
    wallclock_backstop_ms: Option<u64>,
    #[serde(default)]
    max_output_bytes_inline: Option<usize>,
    #[serde(default)]
    max_output_bytes_file: Option<usize>,
    #[serde(default)]
    max_background_jobs: Option<usize>,
}

impl SessionSpec {
    fn into_session(self) -> BashSessionConfig {
        let mut perms = PermissionPolicy::new(self.roots);
        perms.sensitive_patterns = self.sensitive_patterns;
        perms.bypass_workspace_guard = self.bypass_workspace_guard;
        let bash_perms = BashPermissionPolicy::new(perms)
            .with_unsafe_bypass(self.unsafe_allow_bash_without_hook);
        let mut cfg = BashSessionConfig::new(self.cwd, bash_perms, default_executor());
        cfg.default_inactivity_timeout_ms = self.default_inactivity_timeout_ms;
        cfg.wallclock_backstop_ms = self.wallclock_backstop_ms;
        cfg.max_output_bytes_inline = self.max_output_bytes_inline;
        cfg.max_output_bytes_file = self.max_output_bytes_file;
        cfg.max_background_jobs = self.max_background_jobs;
        cfg = cfg.with_logical_cwd_carry();
        cfg
    }
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let stdin = tokio::io::stdin();
    let stdout = tokio::io::stdout();
    let mut reader = BufReader::new(stdin).lines();
    let mut stdout = stdout;

    while let Some(line) = reader.next_line().await? {
        if line.trim().is_empty() {
            continue;
        }
        let resp = handle_line(&line).await;
        let encoded = serde_json::to_string(&resp).unwrap_or_else(|_| {
            r#"{"id":null,"error":{"code":-32603,"message":"serialization failed"}}"#.to_string()
        });
        stdout.write_all(encoded.as_bytes()).await?;
        stdout.write_all(b"\n").await?;
        stdout.flush().await?;
    }
    Ok(())
}

async fn handle_line(line: &str) -> Response {
    let req: Request = match serde_json::from_str(line) {
        Ok(r) => r,
        Err(e) => {
            return Response {
                id: Value::Null,
                result: None,
                error: Some(RpcError {
                    code: -32700,
                    message: format!("parse error: {}", e),
                }),
            };
        }
    };

    let call: Call = match serde_json::from_value(req.params.clone()) {
        Ok(c) => c,
        Err(e) => {
            return Response {
                id: req.id,
                result: Some(tool_error_to_value(ToolError::new(
                    ToolErrorCode::InvalidParam,
                    format!("malformed params: {}", e),
                ))),
                error: None,
            };
        }
    };

    let session = call.session.into_session();

    let result = match req.method.as_str() {
        "bash" => serde_json::to_value(bash(call.params, &session).await).ok(),
        "bash_output" => serde_json::to_value(bash_output(call.params, &session).await).ok(),
        "bash_kill" => serde_json::to_value(bash_kill(call.params, &session).await).ok(),
        other => {
            return Response {
                id: req.id,
                result: None,
                error: Some(RpcError {
                    code: -32601,
                    message: format!("method not found: {}", other),
                }),
            };
        }
    };

    Response {
        id: req.id,
        result,
        error: None,
    }
}

fn tool_error_to_value(e: ToolError) -> Value {
    serde_json::json!({
        "kind": "error",
        "error": e,
    })
}