Skip to main content

wraith_runtime/
bash.rs

1use std::env;
2use std::io;
3use std::process::{Command, Stdio};
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7use tokio::process::Command as TokioCommand;
8use tokio::runtime::Builder;
9use tokio::time::timeout;
10
11use crate::sandbox::{
12    build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
13    SandboxConfig, SandboxStatus,
14};
15use crate::ConfigLoader;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct BashCommandInput {
19    pub command: String,
20    pub timeout: Option<u64>,
21    pub description: Option<String>,
22    #[serde(rename = "run_in_background")]
23    pub run_in_background: Option<bool>,
24    #[serde(rename = "dangerouslyDisableSandbox")]
25    pub dangerously_disable_sandbox: Option<bool>,
26    #[serde(rename = "namespaceRestrictions")]
27    pub namespace_restrictions: Option<bool>,
28    #[serde(rename = "isolateNetwork")]
29    pub isolate_network: Option<bool>,
30    #[serde(rename = "filesystemMode")]
31    pub filesystem_mode: Option<FilesystemIsolationMode>,
32    #[serde(rename = "allowedMounts")]
33    pub allowed_mounts: Option<Vec<String>>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct BashCommandOutput {
38    pub stdout: String,
39    pub stderr: String,
40    #[serde(rename = "rawOutputPath")]
41    pub raw_output_path: Option<String>,
42    pub interrupted: bool,
43    #[serde(rename = "isImage")]
44    pub is_image: Option<bool>,
45    #[serde(rename = "backgroundTaskId")]
46    pub background_task_id: Option<String>,
47    #[serde(rename = "backgroundedByUser")]
48    pub backgrounded_by_user: Option<bool>,
49    #[serde(rename = "assistantAutoBackgrounded")]
50    pub assistant_auto_backgrounded: Option<bool>,
51    #[serde(rename = "dangerouslyDisableSandbox")]
52    pub dangerously_disable_sandbox: Option<bool>,
53    #[serde(rename = "returnCodeInterpretation")]
54    pub return_code_interpretation: Option<String>,
55    #[serde(rename = "noOutputExpected")]
56    pub no_output_expected: Option<bool>,
57    #[serde(rename = "structuredContent")]
58    pub structured_content: Option<Vec<serde_json::Value>>,
59    #[serde(rename = "persistedOutputPath")]
60    pub persisted_output_path: Option<String>,
61    #[serde(rename = "persistedOutputSize")]
62    pub persisted_output_size: Option<u64>,
63    #[serde(rename = "sandboxStatus")]
64    pub sandbox_status: Option<SandboxStatus>,
65}
66
67pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
68    let cwd = env::current_dir()?;
69    let sandbox_status = sandbox_status_for_input(&input, &cwd);
70
71    if input.run_in_background.unwrap_or(false) {
72        let mut child = prepare_command(&input.command, &cwd, &sandbox_status, false);
73        let child = child
74            .stdin(Stdio::null())
75            .stdout(Stdio::null())
76            .stderr(Stdio::null())
77            .spawn()?;
78
79        return Ok(BashCommandOutput {
80            stdout: String::new(),
81            stderr: String::new(),
82            raw_output_path: None,
83            interrupted: false,
84            is_image: None,
85            background_task_id: Some(child.id().to_string()),
86            backgrounded_by_user: Some(false),
87            assistant_auto_backgrounded: Some(false),
88            dangerously_disable_sandbox: input.dangerously_disable_sandbox,
89            return_code_interpretation: None,
90            no_output_expected: Some(true),
91            structured_content: None,
92            persisted_output_path: None,
93            persisted_output_size: None,
94            sandbox_status: Some(sandbox_status),
95        });
96    }
97
98    let runtime = Builder::new_current_thread().enable_all().build()?;
99    runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
100}
101
102async fn execute_bash_async(
103    input: BashCommandInput,
104    sandbox_status: SandboxStatus,
105    cwd: std::path::PathBuf,
106) -> io::Result<BashCommandOutput> {
107    let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
108
109    let output_result = if let Some(timeout_ms) = input.timeout {
110        match timeout(Duration::from_millis(timeout_ms), command.output()).await {
111            Ok(result) => (result?, false),
112            Err(_) => {
113                return Ok(BashCommandOutput {
114                    stdout: String::new(),
115                    stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
116                    raw_output_path: None,
117                    interrupted: true,
118                    is_image: None,
119                    background_task_id: None,
120                    backgrounded_by_user: None,
121                    assistant_auto_backgrounded: None,
122                    dangerously_disable_sandbox: input.dangerously_disable_sandbox,
123                    return_code_interpretation: Some(String::from("timeout")),
124                    no_output_expected: Some(true),
125                    structured_content: None,
126                    persisted_output_path: None,
127                    persisted_output_size: None,
128                    sandbox_status: Some(sandbox_status),
129                });
130            }
131        }
132    } else {
133        (command.output().await?, false)
134    };
135
136    let (output, interrupted) = output_result;
137    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
138    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
139    let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());
140    let return_code_interpretation = output.status.code().and_then(|code| {
141        if code == 0 {
142            None
143        } else {
144            Some(format!("exit_code:{code}"))
145        }
146    });
147
148    Ok(BashCommandOutput {
149        stdout,
150        stderr,
151        raw_output_path: None,
152        interrupted,
153        is_image: None,
154        background_task_id: None,
155        backgrounded_by_user: None,
156        assistant_auto_backgrounded: None,
157        dangerously_disable_sandbox: input.dangerously_disable_sandbox,
158        return_code_interpretation,
159        no_output_expected,
160        structured_content: None,
161        persisted_output_path: None,
162        persisted_output_size: None,
163        sandbox_status: Some(sandbox_status),
164    })
165}
166
167fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) -> SandboxStatus {
168    let config = ConfigLoader::default_for(cwd).load().map_or_else(
169        |_| SandboxConfig::default(),
170        |runtime_config| runtime_config.sandbox().clone(),
171    );
172    let request = config.resolve_request(
173        input.dangerously_disable_sandbox.map(|disabled| !disabled),
174        input.namespace_restrictions,
175        input.isolate_network,
176        input.filesystem_mode,
177        input.allowed_mounts.clone(),
178    );
179    resolve_sandbox_status_for_request(&request, cwd)
180}
181
182fn prepare_command(
183    command: &str,
184    cwd: &std::path::Path,
185    sandbox_status: &SandboxStatus,
186    create_dirs: bool,
187) -> Command {
188    if create_dirs {
189        prepare_sandbox_dirs(cwd);
190    }
191
192    if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
193        let mut prepared = Command::new(launcher.program);
194        prepared.args(launcher.args);
195        prepared.current_dir(cwd);
196        prepared.envs(launcher.env);
197        return prepared;
198    }
199
200    let mut prepared = Command::new("sh");
201    prepared.arg("-lc").arg(command).current_dir(cwd);
202    if sandbox_status.filesystem_active {
203        prepared.env("HOME", cwd.join(".sandbox-home"));
204        prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
205    }
206    prepared
207}
208
209fn prepare_tokio_command(
210    command: &str,
211    cwd: &std::path::Path,
212    sandbox_status: &SandboxStatus,
213    create_dirs: bool,
214) -> TokioCommand {
215    if create_dirs {
216        prepare_sandbox_dirs(cwd);
217    }
218
219    if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
220        let mut prepared = TokioCommand::new(launcher.program);
221        prepared.args(launcher.args);
222        prepared.current_dir(cwd);
223        prepared.envs(launcher.env);
224        return prepared;
225    }
226
227    let mut prepared = TokioCommand::new("sh");
228    prepared.arg("-lc").arg(command).current_dir(cwd);
229    if sandbox_status.filesystem_active {
230        prepared.env("HOME", cwd.join(".sandbox-home"));
231        prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
232    }
233    prepared
234}
235
236fn prepare_sandbox_dirs(cwd: &std::path::Path) {
237    let _ = std::fs::create_dir_all(cwd.join(".sandbox-home"));
238    let _ = std::fs::create_dir_all(cwd.join(".sandbox-tmp"));
239}
240
241#[cfg(test)]
242mod tests {
243    use super::{execute_bash, BashCommandInput};
244    use crate::sandbox::FilesystemIsolationMode;
245
246    #[test]
247    fn executes_simple_command() {
248        let output = execute_bash(BashCommandInput {
249            command: String::from("printf 'hello'"),
250            timeout: Some(1_000),
251            description: None,
252            run_in_background: Some(false),
253            dangerously_disable_sandbox: Some(false),
254            namespace_restrictions: Some(false),
255            isolate_network: Some(false),
256            filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
257            allowed_mounts: None,
258        })
259        .expect("bash command should execute");
260
261        assert_eq!(output.stdout, "hello");
262        assert!(!output.interrupted);
263        assert!(output.sandbox_status.is_some());
264    }
265
266    #[test]
267    fn disables_sandbox_when_requested() {
268        let output = execute_bash(BashCommandInput {
269            command: String::from("printf 'hello'"),
270            timeout: Some(1_000),
271            description: None,
272            run_in_background: Some(false),
273            dangerously_disable_sandbox: Some(true),
274            namespace_restrictions: None,
275            isolate_network: None,
276            filesystem_mode: None,
277            allowed_mounts: None,
278        })
279        .expect("bash command should execute");
280
281        assert!(!output.sandbox_status.expect("sandbox status").enabled);
282    }
283}