Files
claw-code/rust/crates/runtime/src/bash.rs
T
Dror Meirovichandclawdbot 407a6c62f8 fix(windows): fix native runtime compatibility and enable workspace t… (#3378)
* fix(windows): fix native runtime compatibility and enable workspace tests

Fix shell discovery, hook quoting, native path handling and OAuth
randomness on Windows, make the test fixtures portable, and run the
full workspace test suite in the Windows CI job.

* test(plugins): make lifecycle fixtures Windows-compatible

* fix(windows): address workspace test failures

---------

Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-11-08 19:45:52 +00:00

533 lines
18 KiB
Rust

use std::env;
use std::io;
use std::process::{Command, Stdio};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::process::Command as TokioCommand;
use tokio::runtime::Builder;
use tokio::time::timeout;
use crate::lane_events::{LaneEvent, ShipMergeMethod, ShipProvenance};
use crate::sandbox::{
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
SandboxConfig, SandboxStatus,
};
use crate::ConfigLoader;
/// Input schema for the built-in bash execution tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BashCommandInput {
pub command: String,
pub timeout: Option<u64>,
pub description: Option<String>,
#[serde(rename = "run_in_background")]
pub run_in_background: Option<bool>,
#[serde(rename = "dangerouslyDisableSandbox")]
pub dangerously_disable_sandbox: Option<bool>,
#[serde(rename = "namespaceRestrictions")]
pub namespace_restrictions: Option<bool>,
#[serde(rename = "isolateNetwork")]
pub isolate_network: Option<bool>,
#[serde(rename = "filesystemMode")]
pub filesystem_mode: Option<FilesystemIsolationMode>,
#[serde(rename = "allowedMounts")]
pub allowed_mounts: Option<Vec<String>>,
}
/// Output returned from a bash tool invocation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BashCommandOutput {
pub stdout: String,
pub stderr: String,
#[serde(rename = "rawOutputPath")]
pub raw_output_path: Option<String>,
pub interrupted: bool,
#[serde(rename = "isImage")]
pub is_image: Option<bool>,
#[serde(rename = "backgroundTaskId")]
pub background_task_id: Option<String>,
#[serde(rename = "backgroundedByUser")]
pub backgrounded_by_user: Option<bool>,
#[serde(rename = "assistantAutoBackgrounded")]
pub assistant_auto_backgrounded: Option<bool>,
#[serde(rename = "dangerouslyDisableSandbox")]
pub dangerously_disable_sandbox: Option<bool>,
#[serde(rename = "returnCodeInterpretation")]
pub return_code_interpretation: Option<String>,
#[serde(rename = "noOutputExpected")]
pub no_output_expected: Option<bool>,
#[serde(rename = "structuredContent")]
pub structured_content: Option<Vec<serde_json::Value>>,
#[serde(rename = "persistedOutputPath")]
pub persisted_output_path: Option<String>,
#[serde(rename = "persistedOutputSize")]
pub persisted_output_size: Option<u64>,
#[serde(rename = "sandboxStatus")]
pub sandbox_status: Option<SandboxStatus>,
}
/// Executes a shell command with the requested sandbox settings.
pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
let cwd = env::current_dir()?;
let sandbox_status = sandbox_status_for_input(&input, &cwd);
if input.run_in_background.unwrap_or(false) {
let mut child = prepare_command(&input.command, &cwd, &sandbox_status, false);
let child = child
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
return Ok(BashCommandOutput {
stdout: String::new(),
stderr: String::new(),
raw_output_path: None,
interrupted: false,
is_image: None,
background_task_id: Some(child.id().to_string()),
backgrounded_by_user: Some(false),
assistant_auto_backgrounded: Some(false),
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
return_code_interpretation: None,
no_output_expected: Some(true),
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: Some(sandbox_status),
});
}
let runtime = Builder::new_current_thread().enable_all().build()?;
runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
}
/// Detect git push to main and emit ship provenance event
fn detect_and_emit_ship_prepared(command: &str) {
let trimmed = command.trim();
// Simple detection: git push with main/master
if trimmed.contains("git push") && (trimmed.contains("main") || trimmed.contains("master")) {
// Emit ship.prepared event
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let provenance = ShipProvenance {
source_branch: get_current_branch().unwrap_or_else(|| "unknown".to_string()),
base_commit: get_head_commit().unwrap_or_default(),
commit_count: 0, // Would need to calculate from range
commit_range: "unknown..HEAD".to_string(),
merge_method: ShipMergeMethod::DirectPush,
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
pr_number: None,
};
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
// Log to stderr as interim routing before event stream integration
eprintln!(
"[ship.prepared] branch={} -> main, commits={}, actor={}",
provenance.source_branch, provenance.commit_count, provenance.actor
);
}
}
fn get_current_branch() -> Option<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_head_commit() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_git_actor() -> Option<String> {
let name = Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
Some(name)
}
async fn execute_bash_async(
input: BashCommandInput,
sandbox_status: SandboxStatus,
cwd: std::path::PathBuf,
) -> io::Result<BashCommandOutput> {
// Detect and emit ship provenance for git push operations
detect_and_emit_ship_prepared(&input.command);
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
let output_result = if let Some(timeout_ms) = input.timeout {
if let Ok(result) = timeout(Duration::from_millis(timeout_ms), command.output()).await {
(result?, false)
} else {
return Ok(timeout_output(&input, timeout_ms, sandbox_status));
}
} else {
(command.output().await?, false)
};
let (output, interrupted) = output_result;
let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());
let return_code_interpretation = output.status.code().and_then(|code| {
if code == 0 {
None
} else {
Some(format!("exit_code:{code}"))
}
});
Ok(BashCommandOutput {
stdout,
stderr,
raw_output_path: None,
interrupted,
is_image: None,
background_task_id: None,
backgrounded_by_user: None,
assistant_auto_backgrounded: None,
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
return_code_interpretation,
no_output_expected,
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: Some(sandbox_status),
})
}
fn timeout_output(
input: &BashCommandInput,
timeout_ms: u64,
sandbox_status: SandboxStatus,
) -> BashCommandOutput {
let is_test = is_test_command(&input.command);
let return_code_interpretation = if is_test { "test.hung" } else { "timeout" };
BashCommandOutput {
stdout: String::new(),
stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
raw_output_path: None,
interrupted: true,
is_image: None,
background_task_id: None,
backgrounded_by_user: None,
assistant_auto_backgrounded: None,
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
return_code_interpretation: Some(String::from(return_code_interpretation)),
no_output_expected: Some(true),
structured_content: Some(vec![test_timeout_provenance(
&input.command,
timeout_ms,
is_test,
)]),
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: Some(sandbox_status),
}
}
fn is_test_command(command: &str) -> bool {
let normalized = command
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
normalized.contains("cargo test")
|| normalized.contains("cargo nextest")
|| normalized.contains("npm test")
|| normalized.contains("pnpm test")
|| normalized.contains("yarn test")
|| normalized.contains("pytest")
}
fn test_timeout_provenance(
command: &str,
timeout_ms: u64,
classified_as_test_hang: bool,
) -> serde_json::Value {
json!({
"event": if classified_as_test_hang { "test.hung" } else { "command.timeout" },
"failureClass": if classified_as_test_hang { "test_hang" } else { "timeout" },
"data": {
"command": command,
"timeoutMs": timeout_ms,
"provenance": "bash.timeout",
"classification": if classified_as_test_hang { "test.hung" } else { "timeout" }
}
})
}
fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) -> SandboxStatus {
let config = ConfigLoader::default_for(cwd).load().map_or_else(
|_| SandboxConfig::default(),
|runtime_config| runtime_config.sandbox().clone(),
);
let request = config.resolve_request(
input.dangerously_disable_sandbox.map(|disabled| !disabled),
input.namespace_restrictions,
input.isolate_network,
input.filesystem_mode,
input.allowed_mounts.clone(),
);
resolve_sandbox_status_for_request(&request, cwd)
}
// Git for Windows normally adds Git/cmd, but not Git/bin, to PATH.
// Prefer a native shell there rather than the Windows WSL bash launcher.
fn posix_shell() -> std::path::PathBuf {
#[cfg(windows)]
{
if let Some(path) = env::var_os("PATH") {
for directory in env::split_paths(&path) {
let shell = directory.join("sh.exe");
if shell.is_file() {
return shell;
}
if directory.join("git.exe").is_file() {
if let Some(root) = directory.parent() {
let shell = root.join("bin").join("sh.exe");
if shell.is_file() {
return shell;
}
}
}
}
}
for variable in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
if let Some(root) = env::var_os(variable) {
for relative in ["Git/bin/sh.exe", "Programs/Git/bin/sh.exe"] {
let shell = std::path::PathBuf::from(&root).join(relative);
if shell.is_file() {
return shell;
}
}
}
}
}
std::path::PathBuf::from("sh")
}
fn prepare_command(
command: &str,
cwd: &std::path::Path,
sandbox_status: &SandboxStatus,
create_dirs: bool,
) -> Command {
if create_dirs {
prepare_sandbox_dirs(cwd);
}
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut prepared = Command::new(launcher.program);
prepared.args(launcher.args);
prepared.current_dir(cwd);
prepared.envs(launcher.env);
return prepared;
}
let mut prepared = Command::new(posix_shell());
prepared.arg("-lc").arg(command).current_dir(cwd);
if sandbox_status.filesystem_active {
prepared.env("HOME", cwd.join(".sandbox-home"));
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
prepared
}
fn prepare_tokio_command(
command: &str,
cwd: &std::path::Path,
sandbox_status: &SandboxStatus,
create_dirs: bool,
) -> TokioCommand {
if create_dirs {
prepare_sandbox_dirs(cwd);
}
let mut prepared =
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut cmd = TokioCommand::new(launcher.program);
cmd.args(launcher.args);
cmd.envs(launcher.env);
cmd
} else {
let mut cmd = TokioCommand::new(posix_shell());
cmd.arg("-lc").arg(command);
if sandbox_status.filesystem_active {
cmd.env("HOME", cwd.join(".sandbox-home"));
cmd.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
cmd
};
prepared.current_dir(cwd);
prepared.stdin(Stdio::null());
prepared
}
fn prepare_sandbox_dirs(cwd: &std::path::Path) {
let _ = std::fs::create_dir_all(cwd.join(".sandbox-home"));
let _ = std::fs::create_dir_all(cwd.join(".sandbox-tmp"));
}
#[cfg(test)]
mod tests {
use super::{execute_bash, BashCommandInput};
use crate::sandbox::FilesystemIsolationMode;
#[test]
fn executes_simple_command() {
let output = execute_bash(BashCommandInput {
command: String::from("printf 'hello'"),
timeout: Some(1_000),
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(false),
namespace_restrictions: Some(false),
isolate_network: Some(false),
filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
allowed_mounts: None,
})
.expect("bash command should execute");
assert_eq!(output.stdout, "hello");
assert!(!output.interrupted);
assert!(output.sandbox_status.is_some());
}
#[test]
fn disables_sandbox_when_requested() {
let output = execute_bash(BashCommandInput {
command: String::from("printf 'hello'"),
timeout: Some(1_000),
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(true),
namespace_restrictions: None,
isolate_network: None,
filesystem_mode: None,
allowed_mounts: None,
})
.expect("bash command should execute");
assert!(!output.sandbox_status.expect("sandbox status").enabled);
}
#[test]
fn timed_out_test_command_is_classified_as_hung_test_with_provenance() {
let output = execute_bash(BashCommandInput {
command: String::from("sleep 1 # cargo test slow_case"),
timeout: Some(1),
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(false),
namespace_restrictions: Some(false),
isolate_network: Some(false),
filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
allowed_mounts: None,
})
.expect("bash command should return structured timeout");
assert!(output.interrupted);
assert_eq!(
output.return_code_interpretation.as_deref(),
Some("test.hung")
);
let structured = output.structured_content.expect("structured content");
assert_eq!(structured[0]["event"], "test.hung");
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
}
#[test]
fn prevents_stdin_hangs_by_redirecting_to_null() {
let output = execute_bash(BashCommandInput {
command: String::from("cat"),
timeout: Some(2_000),
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(true),
namespace_restrictions: None,
isolate_network: None,
filesystem_mode: None,
allowed_mounts: None,
})
.expect("bash command should execute cleanly");
assert!(
!output.interrupted,
"Command hung and was cut off by the timeout!"
);
}
}
/// Maximum output bytes before truncation (16 KiB, matching upstream).
const MAX_OUTPUT_BYTES: usize = 16_384;
/// Truncate output to `MAX_OUTPUT_BYTES`, appending a marker when trimmed.
fn truncate_output(s: &str) -> String {
if s.len() <= MAX_OUTPUT_BYTES {
return s.to_string();
}
// Find the last valid UTF-8 boundary at or before MAX_OUTPUT_BYTES
let mut end = MAX_OUTPUT_BYTES;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let mut truncated = s[..end].to_string();
truncated.push_str("\n\n[output truncated — exceeded 16384 bytes]");
truncated
}
#[cfg(test)]
mod truncation_tests {
use super::*;
#[test]
fn short_output_unchanged() {
let s = "hello world";
assert_eq!(truncate_output(s), s);
}
#[test]
fn long_output_truncated() {
let s = "x".repeat(20_000);
let result = truncate_output(&s);
assert!(result.len() < 20_000);
assert!(result.ends_with("[output truncated — exceeded 16384 bytes]"));
}
#[test]
fn exact_boundary_unchanged() {
let s = "a".repeat(MAX_OUTPUT_BYTES);
assert_eq!(truncate_output(&s), s);
}
#[test]
fn one_over_boundary_truncated() {
let s = "a".repeat(MAX_OUTPUT_BYTES + 1);
let result = truncate_output(&s);
assert!(result.contains("[output truncated"));
}
}