From 2128bcb505732ab87ed02d870b0e1b8cc342d5b0 Mon Sep 17 00:00:00 2001 From: Steve James Date: Fri, 8 Aug 2025 00:39:50 +0200 Subject: [PATCH] kill flaky drains, wait for PS1 + long-timeout bailout, harden shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace time-based drains with marker-based reads: - Only treat PS1 as completion (never PS2) - Regex-parse PS1 to extract exit code; centralize cleanup - Micro-poll while waiting; strip ANSI - Add long-timeout bailout for incomplete input: - On overall timeout: send '\n' -> wait -> send ^D -> wait - Make ^D safe via `set -o ignoreeof` - Startup path: run container with `sleep infinity`; attach via exec(TTY) - Remove `drain()`/`clean_terminal_output()`; add `strip_markers_and_extract_exit_code()` - Tests: stronger 200 assert msg; add base64 pipe case; formatting - Misc: doc comments; slight stop behavior tweak (`Stopped` → NotStarted) - Deps: add `const_format` and `lazy_static` - Timeouts now return 504 - Already-stopped sandbox now returns NotStarted --- Cargo.lock | 28 +++++ Cargo.toml | 2 + src/lib/http.rs | 2 +- src/lib/sandbox/io.rs | 81 +++++++++------ src/lib/sandbox/mod.rs | 208 ++++++++++++++++++++----------------- src/lib/sandbox/shell.rs | 51 +++++---- tests/integration_tests.rs | 57 +++++++--- 7 files changed, 271 insertions(+), 158 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa8c598..400e919 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,26 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const_format" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1985,9 +2005,11 @@ dependencies = [ "bollard", "bytes", "clap", + "const_format", "criterion", "crossterm", "futures", + "lazy_static", "ratatui", "regex", "reqwest", @@ -2415,6 +2437,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 71e7b98..3271ae3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } strip-ansi-escapes = "0.2.0" regex = "1.11.1" +const_format = "0.2.34" +lazy_static = "1.5.0" [dev-dependencies] tokio-test = "0.4" diff --git a/src/lib/http.rs b/src/lib/http.rs index 6a33d12..5add1ab 100644 --- a/src/lib/http.rs +++ b/src/lib/http.rs @@ -38,7 +38,7 @@ impl SandboxError { SandboxError::ContainerReadFailed(_) => StatusCode::INTERNAL_SERVER_ERROR, SandboxError::ExecFailed(_, _) => StatusCode::INTERNAL_SERVER_ERROR, SandboxError::CreateExecFailed(_) => StatusCode::INTERNAL_SERVER_ERROR, - SandboxError::TimeoutWaitingForMarker(_) => StatusCode::INTERNAL_SERVER_ERROR, + SandboxError::TimeoutWaitingForMarker(_) => StatusCode::GATEWAY_TIMEOUT, } } } diff --git a/src/lib/sandbox/io.rs b/src/lib/sandbox/io.rs index 3b8bc29..8a193bb 100644 --- a/src/lib/sandbox/io.rs +++ b/src/lib/sandbox/io.rs @@ -1,8 +1,9 @@ +use super::shell::{PS1_MARKER, PS2_MARKER}; use bytes::Bytes; use futures::{StreamExt, channel::mpsc::UnboundedReceiver}; use strip_ansi_escapes::strip_str; use thiserror::Error; -use tokio::time::{self, Duration, Instant}; +use tokio::time::{self, Duration, Instant, sleep}; #[derive(Error, Debug)] pub enum ReadError { @@ -14,14 +15,14 @@ pub enum ReadError { pub async fn read_stream_until_idle( receiver: &mut UnboundedReceiver, - marker: &str, overall_timeout: f64, idle_timeout: f64, + short_circuit_after_n_markers: usize, ) -> Result { let mut accumulated = String::new(); let start = Instant::now(); - // All the complex loop logic lives here now. + let mut markers_seen = 0; loop { if start.elapsed().as_secs_f64() > overall_timeout { return Err(ReadError::OverallTimeout); @@ -29,45 +30,65 @@ pub async fn read_stream_until_idle( match time::timeout(Duration::from_secs_f64(idle_timeout), receiver.next()).await { Ok(Some(chunk)) => { - accumulated += &String::from_utf8_lossy(&chunk); + let new_chunk = strip_str(String::from_utf8_lossy(&chunk).to_string()); + accumulated += &new_chunk; + // We can't just naively check for markers and break early here as we could have multiple outputs + // split across multiple chunks. This normally happens if the command was multiline. To avoid + // having to rely on the idle timeout only to check for markers, we use the number of newlines + // in the input command as a hint to how many ouputs we should expect. + if OUTPUT_MARKER_REGEX.is_match(&accumulated) { + markers_seen += 1; + if markers_seen >= short_circuit_after_n_markers { + break; + } + } + } + Ok(None) => { + println!("Stream closed in read_stream_until_idle"); + return Err(ReadError::StreamClosed); } - Ok(None) => return Err(ReadError::StreamClosed), Err(_) => { // Idle timeout - if strip_str(&accumulated).contains(marker) { + if OUTPUT_MARKER_REGEX.is_match(&accumulated) { break; } + // Micro-poll for quick checks + sleep(Duration::from_millis(10)).await; } } } - Ok(accumulated) } -pub async fn drain(receiver: &mut UnboundedReceiver, timeout: f64) -> Result { - let mut drained: Vec = Vec::new(); - loop { - match time::timeout(Duration::from_secs_f64(timeout), receiver.next()).await { - Ok(Some(b)) => drained.extend(&b), - Ok(None) => return Err(ReadError::StreamClosed), - Err(_) => break, +pub fn strip_markers_and_extract_exit_code(output: &str) -> (String, i64) { + let mut last_exit_code = -1i64; + // First remove PS2 markers + let mut cleaned = output.replace(&PS2_MARKER, "").to_string(); + + // Then strip output marker (PS1) and extract the exit code + let mut matches = OUTPUT_MARKER_REGEX.captures_iter(&cleaned); + while let Some(cap) = matches.next() { + if let Some(code_str) = cap.get(1) { + last_exit_code = code_str + .as_str() + .parse::() + .expect("Failed to parse exit code"); } } - Ok(String::from_utf8_lossy(&drained).to_string()) + + cleaned = OUTPUT_MARKER_REGEX.replace_all(&cleaned, "").to_string(); + cleaned = cleaned.replace(&PS1_MARKER, ""); + + cleaned = cleaned.trim_end().to_string(); + (cleaned, last_exit_code) } -pub fn clean_terminal_output(output: &str) -> String { - let stripped = strip_str(output); - let mut cleaned = String::new(); - for line in stripped.lines() { - // Handle \r by taking the last segment after \r - if let Some(last_part) = line.rsplit('\r').next() { - cleaned.push_str(last_part); - cleaned.push('\n'); - } else { - cleaned.push_str(line); - cleaned.push('\n'); - } - } - cleaned.trim_end().to_string() -} \ No newline at end of file +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + pub static ref OUTPUT_MARKER_REGEX: Regex = { + let pattern = format!(r"{}(\d+):", regex::escape(PS1_MARKER)); + Regex::new(&pattern).expect("Invalid PS1 marker regex") + }; +} diff --git a/src/lib/sandbox/mod.rs b/src/lib/sandbox/mod.rs index 60dd514..1201f3f 100644 --- a/src/lib/sandbox/mod.rs +++ b/src/lib/sandbox/mod.rs @@ -19,18 +19,28 @@ use tokio::sync::Mutex; use tokio::time::Instant; use tokio::{io::AsyncWriteExt, sync::OwnedSemaphorePermit}; use tracing::error; - pub struct Sandbox { + /// UUID for the sandbox pub id: String, + /// Docker image to use for the sandbox container pub image: String, + /// Commands to run on startup pub setup_commands: String, + /// Instant when the sandbox and container were started pub start_time: Option, + /// Current status of the sandbox status: SandboxStatus, + /// Semaphore permit for the sandbox. Used to limit the number of concurrent sandboxes. permit: Option, + /// Input stream for the sandbox (stdin) input: Option>>>, + /// Output stream for the sandbox (stdout/stderr) output_receiver: Option>>, + /// Docker client docker: Arc, + /// Trajectory of commands executed in the sandbox trajectory: Vec, + /// Last standalone command exit code last_standalone_exit_code: Option, } @@ -38,8 +48,10 @@ impl Sandbox { pub fn new(image: String, setup_commands: String, docker: Arc) -> Self { use uuid::Uuid; + let id = Uuid::new_v4().to_string(); + Sandbox { - id: Uuid::new_v4().to_string(), + id, image, setup_commands, docker, @@ -53,12 +65,6 @@ impl Sandbox { } } - // We use PS1 to track the end of command outputs AND their exit codes. - // To make jailbreaking harder, we use the sandbox UUID as the PS1 marker. - fn get_marker(&self) -> String { - format!("#{}#:", self.id.clone()) - } - pub fn get_status(&self) -> &SandboxStatus { &self.status } @@ -148,7 +154,7 @@ impl Sandbox { let config = bollard::models::ContainerCreateBody { image: Some(self.image.clone()), - cmd: Some(shell::init_cmd()), + cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]), tty: Some(true), open_stdin: Some(true), attach_stdin: Some(true), @@ -266,39 +272,52 @@ impl Sandbox { } async fn attach_and_configure_shell(&mut self) -> Result<()> { - use bollard::query_parameters::AttachContainerOptions; - let container_id = match &self.status { SandboxStatus::Started(cid) => cid, _ => return Err(SandboxError::NotStarted), }; - let attach_options = AttachContainerOptions { - stdin: true, - stdout: true, - stderr: true, - stream: true, - logs: false, - detach_keys: None, - }; - - let attach_res = self + let create_exec_res = self .docker - .attach_container(&container_id.clone(), Some(attach_options)) - .await - .map_err(|e| SandboxError::StartContainerFailed { - message: e.to_string(), - exit_code: None, - logs: String::new(), - })?; + .create_exec( + container_id, + CreateExecOptions { + cmd: Some(shell::init_cmd()), + attach_stdout: Some(true), + attach_stderr: Some(true), + attach_stdin: Some(true), + tty: Some(true), + ..Default::default() + }, + ) + .await?; - let mut output_stream = attach_res.output; - let input = attach_res.input; + let start_exec_res = self + .docker + .start_exec( + &create_exec_res.id, + Some(StartExecOptions { + detach: false, + tty: true, + ..Default::default() + }), + ) + .await?; + let (mut output, input) = match start_exec_res { + StartExecResults::Attached { output, input, .. } => (output, input), + _ => { + return Err(SandboxError::StartContainerFailed { + message: "Failed to start exec, didn't attach.".to_string(), + exit_code: None, + logs: String::new(), + }); + } + }; // Spawn a task to forward the output stream to the channel let (tx, rx) = futures::channel::mpsc::unbounded::(); tokio::spawn(async move { - while let Some(res) = output_stream.next().await { + while let Some(res) = output.next().await { if let Ok(chunk) = res { let bytes = match chunk { LogOutput::Console { message } => message, @@ -314,16 +333,10 @@ impl Sandbox { self.input = Some(Mutex::new(input)); self.output_receiver = Some(Mutex::new(rx)); - { - let mut input_guard = self.input.as_ref().unwrap().lock().await; - input_guard - .write_all(shell::conf_cmd(&self.get_marker()).as_bytes()) - .await - .map_err(|e| SandboxError::ContainerWriteFailed(e.to_string()))?; - } - - self.drain(0.5).await?; + self.write_cmd(shell::CONF_CMD.to_string()).await?; + // self.drain(0.5).await?; + let _ = self.read_until_idle_after_marker(2.0, 0.1, 1).await?; Ok(()) } @@ -338,53 +351,41 @@ impl Sandbox { }; // Write raw command + self.write_cmd(format!("{}\n", &cmd)).await?; + + // Hint how many commands were executed by counting the number of newlines present. + // Might not be an exact match but it allows us to cut the timeout short. + let n_commands_hint = cmd.split('\n').count(); + let output = match self + .read_until_idle_after_marker(2.0, 0.1, n_commands_hint) + .await { - let mut input = self - .input - .as_ref() - .ok_or(SandboxError::NotStarted)? - .lock() - .await; - input - .write_all(format!("{}\n", cmd).as_bytes()) - .await - .map_err(|e| { - error!("Error writing to container: {}", e); - SandboxError::ContainerWriteFailed(e.to_string()) - })?; - } - - // Read until prompt marker - let output_raw = self.read_until_idle_after_marker(5.0, 0.5).await?; - - // Clean the output - let cleaned = io::clean_terminal_output(&output_raw); - - // Use regex to find all markers, remove them, and get last exit code - let marker_pattern = format!(r"{}(\d+):", regex::escape(&self.get_marker())); - let marker_regex = regex::Regex::new(&marker_pattern) - .map_err(|e| SandboxError::ContainerReadFailed(e.to_string()))?; - - let mut last_exit_code = -1i64; - let mut matches = marker_regex.captures_iter(&cleaned); - while let Some(cap) = matches.next() { - if let Some(code_str) = cap.get(1) { - last_exit_code = code_str.as_str().parse::().unwrap_or(-1); + Ok(s) => s, + Err(SandboxError::TimeoutWaitingForMarker(_)) => { + // Step 1: try a newline to complete open constructs + self.write_cmd("\n".to_string()).await?; + match self.read_until_idle_after_marker(2.0, 0.2, 1).await { + Ok(s2) => s2, + Err(SandboxError::TimeoutWaitingForMarker(_)) => { + // Step 2: try Ctrl-D (safe due to 'set -o ignoreeof') + self.write_cmd("\x04".to_string()).await?; + // Final attempt to reach PS1 + self.read_until_idle_after_marker(2.0, 0.2, 1).await? + } + Err(e) => return Err(e), + } } - } + Err(e) => return Err(e), + }; - let cleaned_output = marker_regex.replace_all(&cleaned, "").to_string(); - let command_output = cleaned_output.trim_end().to_string(); + // Find all markers, remove them, and get last exit code (if input included multiple commands) + let (output, exit_code) = io::strip_markers_and_extract_exit_code(&output); - let result = CommandResult { - output: command_output, - exit_code: last_exit_code, - }; + let result = CommandResult { output, exit_code }; command_execution.result = Some(result.clone()); self.trajectory.push(command_execution); // Drain any remaining output to next prompt - self.drain(0.5).await?; Ok(result) } @@ -447,7 +448,7 @@ impl Sandbox { self.permit.take(); return match &self.status { - SandboxStatus::Stopped(_) => Ok(()), // Already stopped + SandboxStatus::Stopped(_) => Err(SandboxError::NotStarted), // Already stopped SandboxStatus::Created => Err(SandboxError::NotStarted), SandboxStatus::Started(cid) => { // Stop the container but don't remove it @@ -470,32 +471,51 @@ impl Sandbox { }; } - pub async fn drain(&mut self, timeout: f64) -> Result { - let receiver = self - .output_receiver + async fn write_cmd(&mut self, cmd: String) -> Result<()> { + let mut input = self + .input .as_ref() - .ok_or(SandboxError::NotStarted)?; - let mut receiver_guard = receiver.lock().await; - let drained = io::drain(&mut receiver_guard, timeout).await; - drained.map_err(|e| SandboxError::ContainerReadFailed(e.to_string())) + .ok_or(SandboxError::NotStarted)? + .lock() + .await; + input + .write_all(cmd.as_bytes()) + .await + .map_err(|e| SandboxError::ContainerWriteFailed(e.to_string()))?; + input + .flush() + .await + .map_err(|e| SandboxError::ContainerWriteFailed(e.to_string()))?; + Ok(()) } - pub async fn read_until_idle_after_marker( + async fn read_until_idle_after_marker( &mut self, overall_timeout: f64, idle_timeout: f64, + short_circuit_after_n_markers: usize, ) -> Result { let receiver = self .output_receiver .as_ref() .ok_or(SandboxError::NotStarted)?; let mut receiver_guard = receiver.lock().await; - let marker = self.get_marker(); - let result = - io::read_stream_until_idle(&mut receiver_guard, &marker, overall_timeout, idle_timeout) - .await; - - result.map_err(|e| SandboxError::ContainerReadFailed(e.to_string())) + let result = io::read_stream_until_idle( + &mut receiver_guard, + overall_timeout, + idle_timeout, + short_circuit_after_n_markers, + ) + .await; + match result { + Ok(s) => Ok(s), + Err(io::ReadError::OverallTimeout) => Err(SandboxError::TimeoutWaitingForMarker( + "Marker not seen before timeout (possible incomplete input)".to_string(), + )), + Err(io::ReadError::StreamClosed) => Err(SandboxError::ContainerReadFailed( + "Stream closed unexpectedly".to_string(), + )), + } } } diff --git a/src/lib/sandbox/shell.rs b/src/lib/sandbox/shell.rs index f9cbbdc..0d3b68e 100644 --- a/src/lib/sandbox/shell.rs +++ b/src/lib/sandbox/shell.rs @@ -1,37 +1,46 @@ -// Disables stdin from being echoed back to the terminal. -const SILENCE_INPUT: &str = "stty -echo"; +use const_format::{concatcp, formatcp}; + +// Change this +pub const UNIQUE_MARKER: &str = "TR0N-F1GHTS-4-TH3-U23R2"; +pub const PS1_MARKER: &str = formatcp!("#PS1-{}#:", UNIQUE_MARKER); +pub const PS2_MARKER: &str = formatcp!("#PS2-{}#:", UNIQUE_MARKER); +const PS1: &str = formatcp!("{}$?:", PS1_MARKER); // Also includes the exit code +const PS2: &str = formatcp!("{}", PS2_MARKER); + +// Disables stdin from being echoe back to the terminal. +const SILENCE_INPUT: &str = "stty -echo; "; // Disables bracketed paste mode which adds a lot of noise to the output. -const DISABLE_BRACKETED_PASTE: &str = "bind 'set enable-bracketed-paste off'"; +const DISABLE_BRACKETED_PASTE: &str = "bind 'set enable-bracketed-paste off'; "; // Sets the prompt to include the exit code of the last standalone command. -const PROMPT: &str = "PS1='{MARKER}$?:'"; +const SET_PS1: &str = formatcp!("PS1='{}'; ", PS1); // Disables the input prompt, should never be used anyway but just in case. -const PROMPT_2: &str = "PS2=''"; +const SET_PS2: &str = formatcp!("PS2='{}'; ", PS2); // Since we use the prompts to detect when commands finish, we need to make sure they are not overwritten -// to avoid jailbreaks in the simulation. -const READONLY_PROMPTS: &str = "readonly PS1; readonly PS2"; +// to avoid jailbreaks in the simulation. Agent can still echo the prompts though. +const READONLY_PROMPTS: &str = "readonly PS1; readonly PS2; "; // Overrides the default exit command to not exit the shell. // TODO: echo special marker on exit to terminate the session. -const EXIT_COMMAND: &str = "exit() { return 0; }; export -f exit"; +const EXIT_COMMAND: &str = "exit() { return 0; }; export -f exit; "; + +// Ignore EOF to prevent the shell from exiting when the input stream is closed. +const IGNORE_EOF: &str = "set -o ignoreeof; "; /// Builds the command to configure the shell. -pub fn conf_cmd(marker: &str) -> String { - let prompt = &PROMPT.replace("{MARKER}", marker); - let init_cmds = vec![ - SILENCE_INPUT, - DISABLE_BRACKETED_PASTE, - prompt, - PROMPT_2, - READONLY_PROMPTS, - EXIT_COMMAND, - ]; - - format!("{}\n", init_cmds.join("; ")) -} +pub const CONF_CMD: &str = concatcp!( + SILENCE_INPUT, + DISABLE_BRACKETED_PASTE, + SET_PS1, + SET_PS2, + READONLY_PROMPTS, + EXIT_COMMAND, + IGNORE_EOF, + "\n" +); // TODO: support any POSIX shell pub fn standalone_cmd(cmd: &str) -> Vec { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 1226848..6522029 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -82,7 +82,7 @@ async fn execute_command( let mut exec_payload = json!({ "command": command }); - + if let Some(standalone_value) = standalone { exec_payload["standalone"] = json!(standalone_value); } @@ -94,7 +94,13 @@ async fn execute_command( .await .expect("Failed to send exec request"); - assert_eq!(response.status(), 200, "Exec command should return 200"); + let status = response.status().clone(); + assert_eq!( + status, + 200, + "{}", + response.text().await.unwrap() + ); response .json() @@ -180,7 +186,14 @@ async fn test_execute_command() { let sandbox_id = create_and_start_sandbox(&client, &base_url).await; // Test executing a command - let exec_result = execute_command(&client, &base_url, &sandbox_id, "echo 'Hello, World!' && cd not-exists", None).await; + let exec_result = execute_command( + &client, + &base_url, + &sandbox_id, + "echo 'Hello, World!' && cd not-exists", + None, + ) + .await; assert_eq!( exec_result["output"], "Hello, World!\nbash: cd: not-exists: No such file or directory", @@ -201,7 +214,8 @@ async fn test_comment_commands() { let sandbox_id = create_and_start_sandbox(&client, &base_url).await; // Test executing a comment (should be ignored) - let comment_result = execute_command(&client, &base_url, &sandbox_id, "# This is a comment", None).await; + let comment_result = + execute_command(&client, &base_url, &sandbox_id, "# This is a comment", None).await; assert_eq!( comment_result["exit_code"], 0, @@ -304,6 +318,16 @@ async fn test_piping_and_redirection() { "Piping should return exit code 0" ); + let exec_result = execute_command(&client, &base_url, &sandbox_id, "echo 'Q0xVIFdBUyBIRVJF' | base64 -d ", None).await; + assert_eq!( + exec_result["output"], "CLU WAS HERE", + "Output should contain 'CLU WAS HERE'" + ); + assert_eq!( + exec_result["exit_code"], 0, + "Piping should return exit code 0" + ); + // Cleanup cleanup_sandbox(&client, &base_url, &sandbox_id).await; } @@ -340,7 +364,6 @@ async fn test_stop_sandbox() { ); } - #[tokio::test] async fn test_error_conditions() { let base_url = start_test_server().await; @@ -455,9 +478,13 @@ async fn test_multiline_commands() { // Test multi-line command with literal newlines let multiline_command = "echo 'First line'\necho 'Second line'\necho 'Third line'"; - let exec_result = execute_command(&client, &base_url, &sandbox_id, multiline_command, None).await; + let exec_result = + execute_command(&client, &base_url, &sandbox_id, multiline_command, None).await; - assert_eq!(exec_result["exit_code"], 0, "Multi-line command should succeed"); + assert_eq!( + exec_result["exit_code"], 0, + "Multi-line command should succeed" + ); assert_eq!( exec_result["output"], "First line\nSecond line\nThird line", "Output should contain all three lines" @@ -465,7 +492,8 @@ async fn test_multiline_commands() { // Test multi-line command with variable assignment and usage let script_command = "NAME='World'\necho \"Hello, $NAME!\"\necho \"Goodbye, $NAME!\""; - let script_result = execute_command(&client, &base_url, &sandbox_id, script_command, None).await; + let script_result = + execute_command(&client, &base_url, &sandbox_id, script_command, None).await; assert_eq!(script_result["exit_code"], 0, "Script should succeed"); assert_eq!( @@ -474,10 +502,15 @@ async fn test_multiline_commands() { ); // Test multi-line command with conditional logic - let conditional_command = "if [ 1 -eq 1 ]; then\n echo 'Condition is true'\nelse\n echo 'Condition is false'\nfi"; - let conditional_result = execute_command(&client, &base_url, &sandbox_id, conditional_command, None).await; + let conditional_command = + "if [ 1 -eq 1 ]; then\n echo 'Condition is true'\nelse\n echo 'Condition is false'\nfi"; + let conditional_result = + execute_command(&client, &base_url, &sandbox_id, conditional_command, None).await; - assert_eq!(conditional_result["exit_code"], 0, "Conditional should succeed"); + assert_eq!( + conditional_result["exit_code"], 0, + "Conditional should succeed" + ); assert_eq!( conditional_result["output"], "Condition is true", "Conditional output should show correct branch" @@ -494,4 +527,4 @@ async fn test_multiline_commands() { ); cleanup_sandbox(&client, &base_url, &sandbox_id).await; -} \ No newline at end of file +}