vv-agent 0.6.2

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
use std::time::Duration;

use serde_json::{json, Value};

use crate::runtime::background_sessions::background_session_manager;
use crate::runtime::processes::{
    read_captured_output, remove_captured_output, start_captured_process_with_env, wait_for_child,
};
use crate::runtime::shell::prepare_shell_execution;
use crate::tools::base::ToolContext;
use crate::tools::common::{
    coerce_truthy_arg, parse_integer_arg, path_escapes_workspace_error, stringify_tool_arg,
    tool_error_with_code, tool_result, workspace_relative_path_or_absolute,
};
use crate::types::{ToolArguments, ToolDirective, ToolExecutionResult, ToolResultStatus};

use super::env::build_process_env;
use super::shell_defaults::read_shell_defaults;

pub(super) fn execute_bash_command(
    context: &mut ToolContext,
    arguments: &ToolArguments,
) -> ToolExecutionResult {
    let command = stringify_tool_arg(arguments.get("command"), "")
        .trim()
        .to_string();
    if command.is_empty() {
        return tool_error_with_code("`command` is required", "command_required");
    }
    if let Some(snippet) = blocked_dangerous_snippet(&command) {
        return tool_error_with_code(
            format!("dangerous command blocked: {snippet}"),
            "dangerous_command",
        );
    }

    let exec_dir = stringify_tool_arg(arguments.get("exec_dir"), ".");
    let cwd = match context.resolve_workspace_path(&exec_dir) {
        Ok(cwd) => cwd,
        Err(error) => return path_escapes_workspace_error(error),
    };
    if !cwd.is_dir() {
        return tool_error_with_code(
            format!("exec_dir not found: {exec_dir}"),
            "invalid_exec_dir",
        );
    }

    let timeout_seconds = match read_timeout_seconds(arguments) {
        Ok(timeout_seconds) => timeout_seconds,
        Err(error) => return tool_error_with_code(error, "invalid_timeout"),
    };
    let stdin_text = arguments
        .contains_key("stdin")
        .then(|| stringify_tool_arg(arguments.get("stdin"), ""));
    let auto_confirm = json_truthy_argument(arguments, "auto_confirm");
    let run_in_background = json_truthy_argument(arguments, "run_in_background");
    let (shell, windows_shell_priority, bash_env) = match read_shell_defaults(&context.metadata) {
        Ok(defaults) => defaults,
        Err(error) => return tool_error_with_code(error, "invalid_shell_config"),
    };
    let process_env = build_process_env(bash_env.as_ref());
    let prepared = match prepare_shell_execution(
        &command,
        auto_confirm,
        stdin_text.as_deref(),
        shell.as_deref(),
        windows_shell_priority.as_deref(),
    ) {
        Ok(prepared) => prepared,
        Err(error) => return tool_error_with_code(error, "invalid_shell_config"),
    };
    let started = start_captured_process_with_env(
        &prepared.command,
        &cwd,
        prepared.stdin.as_deref(),
        process_env.as_ref(),
    );
    let mut started = match started {
        Ok(started) => started,
        Err(error) => {
            let shell = prepared.shell.unwrap_or_else(|| "shell".to_string());
            return tool_error_with_code(
                format!("Failed to start {shell}: {error}"),
                "command_failed",
            );
        }
    };

    if run_in_background {
        let session_id = background_session_manager().adopt_running_process(
            command,
            cwd,
            timeout_seconds,
            started.child,
            started.output_path,
            prepared.shell,
        );
        let payload = json!({
            "status": "running",
            "session_id": session_id,
        });
        return tool_result(
            ToolResultStatus::Running,
            payload,
            None,
            ToolDirective::Continue,
        );
    }

    match wait_for_child(&mut started.child, Duration::from_secs(timeout_seconds)) {
        Ok(Some(exit_status)) => {
            let output = read_captured_output(&started.output_path, 50_000);
            remove_captured_output(&started.output_path);
            let exit_code = exit_status.code().unwrap_or(-1);
            let mut payload = json!({
                "cwd": workspace_relative_path_or_absolute(&context.workspace, &cwd),
                "exit_code": exit_code,
                "output": output,
            });
            if let Some(shell) = prepared.shell {
                payload["shell"] = Value::String(shell);
            }
            if exit_code == 0 {
                ToolExecutionResult::success("", payload.to_string())
            } else {
                tool_result(
                    ToolResultStatus::Error,
                    payload,
                    Some("command_failed"),
                    ToolDirective::Continue,
                )
            }
        }
        Ok(None) => {
            let output = read_captured_output(&started.output_path, 50_000);
            let session_id = background_session_manager().adopt_running_process(
                command,
                cwd,
                timeout_seconds,
                started.child,
                started.output_path,
                prepared.shell.clone(),
            );
            let mut payload = json!({
                "status": "running",
                "session_id": session_id,
                "cwd": exec_dir,
                "message": format!(
                    "command exceeded foreground timeout after {timeout_seconds} seconds and continues in background; use `check_background_command` with this session_id to inspect progress"
                ),
                "output": output,
                "transitioned_to_background": true,
            });
            if let Some(shell) = prepared.shell {
                payload["shell"] = Value::String(shell);
            }
            tool_result(
                ToolResultStatus::Running,
                payload,
                None,
                ToolDirective::Continue,
            )
        }
        Err(error) => tool_error_with_code(error.to_string(), "command_failed"),
    }
}

fn blocked_dangerous_snippet(command: &str) -> Option<&'static str> {
    let lowered = command.to_ascii_lowercase();
    [
        "rm -rf /",
        "shutdown",
        "reboot",
        "mkfs",
        "dd if=/dev/zero of=/dev/",
    ]
    .into_iter()
    .find(|snippet| lowered.contains(snippet))
}

fn read_timeout_seconds(arguments: &ToolArguments) -> Result<u64, String> {
    match arguments.get("timeout") {
        Some(value) => match parse_integer_arg(value) {
            Ok(timeout) => Ok(timeout.clamp(1, 600) as u64),
            Err(_) => Err("`timeout` must be an integer".to_string()),
        },
        None => Ok(300),
    }
}

fn json_truthy_argument(arguments: &ToolArguments, name: &str) -> bool {
    arguments
        .get(name)
        .map(|value| coerce_truthy_arg(Some(value), false))
        .unwrap_or(false)
}