diff --git a/Cargo.lock b/Cargo.lock index 0733146ae..18baa3bac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,6 +322,22 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "arc-sprites" +version = "0.1.0" +dependencies = [ + "arc-agent", + "async-trait", + "base64", + "chrono", + "rand 0.8.5", + "serde", + "tempfile", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "arc-types" version = "0.1.0" diff --git a/crates/arc-sprites/Cargo.toml b/crates/arc-sprites/Cargo.toml new file mode 100644 index 000000000..a8c25d61f --- /dev/null +++ b/crates/arc-sprites/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "arc-sprites" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Sprites (Fly.io) VM sandbox for Arc agent tool operations" + +[lib] +doctest = false + +[dependencies] +arc-agent = { path = "../arc-agent" } +async-trait.workspace = true +tokio.workspace = true +tokio-util.workspace = true +serde.workspace = true +chrono.workspace = true +rand.workspace = true +tracing.workspace = true +base64.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util", "macros"] } +tempfile = "3" diff --git a/crates/arc-sprites/src/cli_runner.rs b/crates/arc-sprites/src/cli_runner.rs new file mode 100644 index 000000000..0958a04df --- /dev/null +++ b/crates/arc-sprites/src/cli_runner.rs @@ -0,0 +1,41 @@ +use async_trait::async_trait; + +use crate::{SpriteOutput, SpriteRunner}; + +/// Real implementation that invokes the `sprite` CLI binary. +#[derive(Default)] +pub struct CliSpriteRunner; + +impl CliSpriteRunner { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl SpriteRunner for CliSpriteRunner { + async fn run(&self, args: &[&str]) -> Result { + let output = tokio::process::Command::new("sprite") + .args(args) + .output() + .await + .map_err(|e| format!("Failed to run sprite command: {e}"))?; + + Ok(SpriteOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + }) + } + + async fn run_with_timeout( + &self, + args: &[&str], + timeout: std::time::Duration, + ) -> Result { + match tokio::time::timeout(timeout, self.run(args)).await { + Ok(result) => result, + Err(_) => Err("Command timed out".to_string()), + } + } +} diff --git a/crates/arc-sprites/src/lib.rs b/crates/arc-sprites/src/lib.rs new file mode 100644 index 000000000..f9417cb5f --- /dev/null +++ b/crates/arc-sprites/src/lib.rs @@ -0,0 +1,1237 @@ +mod cli_runner; + +use std::collections::HashMap; +use std::path::Path; +use std::time::Instant; + +use arc_agent::sandbox::{ + format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; + +pub use cli_runner::CliSpriteRunner; + +const WORKING_DIRECTORY: &str = "/home/sprite"; +const PROVIDER: &str = "sprites"; + +/// Output from a sprite CLI command execution. +pub struct SpriteOutput { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +/// Trait abstracting sprite CLI operations for testability. +#[async_trait] +pub trait SpriteRunner: Send + Sync { + async fn run(&self, args: &[&str]) -> Result; + + async fn run_with_timeout( + &self, + args: &[&str], + timeout: std::time::Duration, + ) -> Result; +} + +/// Configuration for a Sprites sandbox (TOML target for `[sandbox.sprites]`). +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct SpritesConfig { + pub org: Option, + pub url_auth: Option, + pub sprite_name: Option, +} + +/// Sandbox that runs all operations inside a Sprites VM via the `sprite` CLI. +pub struct SpritesSandbox { + config: SpritesConfig, + runner: Box, + sprite_name: tokio::sync::OnceCell, + preview_url: tokio::sync::OnceCell, + rg_available: tokio::sync::OnceCell, + event_callback: Option, + preserve_sprite: bool, +} + +impl SpritesSandbox { + pub fn new(runner: Box, config: SpritesConfig) -> Self { + let preserve_sprite = config.sprite_name.is_some(); + Self { + config, + runner, + sprite_name: tokio::sync::OnceCell::new(), + preview_url: tokio::sync::OnceCell::new(), + rg_available: tokio::sync::OnceCell::const_new(), + event_callback: None, + preserve_sprite, + } + } + + pub fn set_event_callback(&mut self, cb: SandboxEventCallback) { + self.event_callback = Some(cb); + } + + fn emit(&self, event: SandboxEvent) { + event.trace(); + if let Some(ref cb) = self.event_callback { + cb(event); + } + } + + fn resolve_path(&self, path: &str) -> String { + if Path::new(path).is_absolute() { + path.to_string() + } else { + format!("{WORKING_DIRECTORY}/{path}") + } + } + + fn sprite_name(&self) -> Result<&str, String> { + self.sprite_name + .get() + .map(|s| s.as_str()) + .ok_or_else(|| "Sprites sandbox not initialized — call initialize() first".to_string()) + } + + /// Build args for `sprite exec -s [-o org] bash -c `. + fn build_exec_args(&self, command: &str) -> Result, String> { + let name = self.sprite_name()?; + let mut args = vec![ + "exec".to_string(), + "-s".to_string(), + name.to_string(), + ]; + if let Some(ref org) = self.config.org { + args.push("-o".to_string()); + args.push(org.clone()); + } + args.push("bash".to_string()); + args.push("-c".to_string()); + args.push(command.to_string()); + Ok(args) + } + + /// Append `-o ` to base args if org is configured. + fn build_sprite_args(&self, base_args: &[&str]) -> Vec { + let mut args: Vec = base_args.iter().map(|s| s.to_string()).collect(); + if let Some(ref org) = self.config.org { + args.push("-o".to_string()); + args.push(org.clone()); + } + args + } +} + +#[async_trait] +impl Sandbox for SpritesSandbox { + async fn initialize(&self) -> Result<(), String> { + self.emit(SandboxEvent::Initializing { + provider: PROVIDER.into(), + }); + let init_start = Instant::now(); + + let name = if let Some(ref existing) = self.config.sprite_name { + existing.clone() + } else { + let now = chrono::Utc::now(); + let suffix: u16 = rand::random::() % 10000; + let name = format!("arc-{}-{suffix:04}", now.format("%Y%m%d-%H%M%S")); + + let create_args = self.build_sprite_args(&["create", &name, "-skip-console"]); + let create_refs: Vec<&str> = create_args.iter().map(|s| s.as_str()).collect(); + let output = self.runner.run(&create_refs).await.map_err(|e| { + let err = format!("Failed to create sprite: {e}"); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: PROVIDER.into(), + error: err.clone(), + duration_ms, + }); + err + })?; + + if output.exit_code != 0 { + let err = format!( + "Sprite creation failed (exit {}): {}", + output.exit_code, output.stderr + ); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: PROVIDER.into(), + error: err.clone(), + duration_ms, + }); + return Err(err); + } + + name + }; + + self.sprite_name + .set(name.clone()) + .map_err(|_| "Sprites sandbox already initialized".to_string())?; + + // Get preview URL + let url_args = self.build_sprite_args(&["url", "-s", &name]); + let url_refs: Vec<&str> = url_args.iter().map(|s| s.as_str()).collect(); + let url_output = self.runner.run(&url_refs).await.map_err(|e| { + let err = format!("Failed to get sprite URL: {e}"); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: PROVIDER.into(), + error: err.clone(), + duration_ms, + }); + err + })?; + + let url = url_output + .stdout + .lines() + .find_map(|line| line.strip_prefix("URL: ")) + .unwrap_or(url_output.stdout.trim()) + .trim() + .to_string(); + let _ = self.preview_url.set(url.clone()); + + // Set URL auth if configured + if let Some(ref auth_mode) = self.config.url_auth { + let auth_args = + self.build_sprite_args(&["url", "update", "-s", &name, "--auth", auth_mode]); + let auth_refs: Vec<&str> = auth_args.iter().map(|s| s.as_str()).collect(); + self.runner.run(&auth_refs).await.map_err(|e| { + let err = format!("Failed to set URL auth: {e}"); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: PROVIDER.into(), + error: err.clone(), + duration_ms, + }); + err + })?; + } + + let init_duration = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::Ready { + provider: PROVIDER.into(), + duration_ms: init_duration, + name: Some(name), + cpu: None, + memory: None, + url: Some(url), + }); + + Ok(()) + } + + async fn cleanup(&self) -> Result<(), String> { + self.emit(SandboxEvent::CleanupStarted { + provider: PROVIDER.into(), + }); + let start = Instant::now(); + + if !self.preserve_sprite { + if let Some(name) = self.sprite_name.get() { + let args = self.build_sprite_args(&["destroy", "-s", name, "-force"]); + let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + if let Err(e) = self.runner.run(&refs).await { + let err = format!("Failed to destroy sprite: {e}"); + self.emit(SandboxEvent::CleanupFailed { + provider: PROVIDER.into(), + error: err.clone(), + }); + return Err(err); + } + } + } + + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::CleanupCompleted { + provider: PROVIDER.into(), + duration_ms, + }); + Ok(()) + } + + async fn exec_command( + &self, + command: &str, + timeout_ms: u64, + working_dir: Option<&str>, + env_vars: Option<&HashMap>, + _cancel_token: Option, + ) -> Result { + let start = Instant::now(); + + let mut parts = Vec::new(); + + if let Some(vars) = env_vars { + for (key, value) in vars { + parts.push(format!("export {}='{}';", key, value.replace('\'', "'\\''"))); + } + } + + if let Some(dir) = working_dir { + let resolved = self.resolve_path(dir); + parts.push(format!("cd '{}'", resolved.replace('\'', "'\\''"))); + parts.push("&&".to_string()); + } else { + parts.push(format!("cd '{WORKING_DIRECTORY}'")); + parts.push("&&".to_string()); + } + + parts.push(command.to_string()); + let full_cmd = parts.join(" "); + + let args = self.build_exec_args(&full_cmd)?; + let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let timeout = std::time::Duration::from_millis(timeout_ms); + let output = self.runner.run_with_timeout(&refs, timeout).await; + + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + + match output { + Ok(out) => Ok(ExecResult { + stdout: out.stdout, + stderr: out.stderr, + exit_code: out.exit_code, + timed_out: false, + duration_ms, + }), + Err(e) if e.contains("timed out") => Ok(ExecResult { + stdout: String::new(), + stderr: "Command timed out".to_string(), + exit_code: -1, + timed_out: true, + duration_ms, + }), + Err(e) => Err(e), + } + } + + async fn read_file( + &self, + path: &str, + offset: Option, + limit: Option, + ) -> Result { + let resolved = self.resolve_path(path); + let cmd = format!("cat '{}'", resolved.replace('\'', "'\\''")); + + let result = self.exec_command(&cmd, 30_000, None, None, None).await?; + + if result.exit_code != 0 { + return Err(format!("Failed to read {resolved}: {}", result.stderr)); + } + + Ok(format_lines_numbered(&result.stdout, offset, limit)) + } + + async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { + let resolved = self.resolve_path(path); + + // Ensure parent directory exists + if let Some(parent) = Path::new(&resolved).parent() { + let parent_str = parent.to_string_lossy(); + if parent_str != "/" { + let mkdir_cmd = format!("mkdir -p '{}'", parent_str.replace('\'', "'\\''")); + self.exec_command(&mkdir_cmd, 30_000, None, None, None) + .await?; + } + } + + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(content.as_bytes()); + let cmd = format!( + "echo '{}' | base64 -d > '{}'", + encoded, + resolved.replace('\'', "'\\''"), + ); + + let result = self.exec_command(&cmd, 30_000, None, None, None).await?; + if result.exit_code != 0 { + return Err(format!("Failed to write {resolved}: {}", result.stderr)); + } + + Ok(()) + } + + async fn delete_file(&self, path: &str) -> Result<(), String> { + let resolved = self.resolve_path(path); + let cmd = format!("rm -f '{}'", resolved.replace('\'', "'\\''")); + + let result = self.exec_command(&cmd, 30_000, None, None, None).await?; + if result.exit_code != 0 { + return Err(format!("Failed to delete {resolved}: {}", result.stderr)); + } + Ok(()) + } + + async fn file_exists(&self, path: &str) -> Result { + let resolved = self.resolve_path(path); + let cmd = format!("test -e '{}'", resolved.replace('\'', "'\\''")); + + let result = self.exec_command(&cmd, 30_000, None, None, None).await?; + Ok(result.exit_code == 0) + } + + async fn list_directory( + &self, + path: &str, + depth: Option, + ) -> Result, String> { + let resolved = self.resolve_path(path); + let max_depth = depth.unwrap_or(1); + + let cmd = format!( + "find '{}' -mindepth 1 -maxdepth {} -printf '%y\\t%s\\t%P\\n'", + resolved.replace('\'', "'\\''"), + max_depth, + ); + + let result = self.exec_command(&cmd, 30_000, None, None, None).await?; + + if result.exit_code != 0 { + return Err(format!( + "Failed to list directory {resolved}: {}", + result.stderr + )); + } + + let mut entries: Vec = result + .stdout + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(3, '\t').collect(); + if parts.len() < 3 { + return None; + } + let file_type = parts[0]; + let size: Option = parts[1].parse().ok(); + let name = parts[2].to_string(); + let is_dir = file_type == "d"; + Some(DirEntry { + name, + is_dir, + size: if is_dir { None } else { size }, + }) + }) + .collect(); + + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(entries) + } + + async fn grep( + &self, + pattern: &str, + path: &str, + options: &GrepOptions, + ) -> Result, String> { + let resolved = self.resolve_path(path); + + let use_rg = *self + .rg_available + .get_or_init(|| async { + let result = self + .exec_command("rg --version", 10_000, None, None, None) + .await; + matches!(result, Ok(r) if r.exit_code == 0) + }) + .await; + + let cmd = if use_rg { + let mut cmd = "rg --line-number --no-heading".to_string(); + if options.case_insensitive { + cmd.push_str(" -i"); + } + if let Some(ref glob_filter) = options.glob_filter { + cmd.push_str(&format!(" --glob '{glob_filter}'")); + } + if let Some(max) = options.max_results { + cmd.push_str(&format!(" --max-count {max}")); + } + cmd.push_str(&format!( + " -- '{}' '{}'", + pattern.replace('\'', "'\\''"), + resolved + )); + cmd + } else { + let mut cmd = "grep -rn".to_string(); + if options.case_insensitive { + cmd.push_str(" -i"); + } + if let Some(ref glob_filter) = options.glob_filter { + cmd.push_str(&format!(" --include '{glob_filter}'")); + } + if let Some(max) = options.max_results { + cmd.push_str(&format!(" -m {max}")); + } + cmd.push_str(&format!( + " -- '{}' '{}'", + pattern.replace('\'', "'\\''"), + resolved + )); + cmd + }; + + let result = self.exec_command(&cmd, 30_000, None, None, None).await?; + + if result.exit_code == 1 { + return Ok(Vec::new()); + } + if result.exit_code != 0 { + return Err(format!( + "grep failed (exit {}): {}", + result.exit_code, result.stderr + )); + } + + Ok(result.stdout.lines().map(String::from).collect()) + } + + async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { + let base = path + .map(|p| self.resolve_path(p)) + .unwrap_or_else(|| WORKING_DIRECTORY.to_string()); + + let cmd = format!( + "find '{}' -name '{}' -type f | sort", + base.replace('\'', "'\\''"), + pattern.replace('\'', "'\\''"), + ); + + let result = self.exec_command(&cmd, 30_000, None, None, None).await?; + + if result.exit_code != 0 { + return Err(format!( + "glob failed (exit {}): {}", + result.exit_code, result.stderr + )); + } + + Ok(result + .stdout + .lines() + .filter(|l| !l.is_empty()) + .map(String::from) + .collect()) + } + + async fn download_file_to_local( + &self, + remote_path: &str, + local_path: &Path, + ) -> Result<(), String> { + let resolved = self.resolve_path(remote_path); + let cmd = format!("base64 '{}'", resolved.replace('\'', "'\\''")); + + let result = self.exec_command(&cmd, 30_000, None, None, None).await?; + if result.exit_code != 0 { + return Err(format!("Failed to read {resolved}: {}", result.stderr)); + } + + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(result.stdout.trim()) + .map_err(|e| format!("Failed to decode base64: {e}"))?; + + if let Some(parent) = local_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create parent dirs: {e}"))?; + } + tokio::fs::write(local_path, &bytes) + .await + .map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?; + + Ok(()) + } + + fn working_directory(&self) -> &str { + WORKING_DIRECTORY + } + + fn platform(&self) -> &str { + "linux" + } + + fn os_version(&self) -> String { + "Linux (Sprites)".to_string() + } + + fn sandbox_info(&self) -> String { + self.sprite_name.get().cloned().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[derive(Debug, Clone)] + struct RecordedCommand { + args: Vec, + } + + struct MockResponse { + stdout: String, + stderr: String, + exit_code: i32, + } + + struct MockSpriteRunner { + commands: Arc>>, + responses: Arc>>, + } + + impl MockSpriteRunner { + fn new() -> Self { + Self { + commands: Arc::new(Mutex::new(Vec::new())), + responses: Arc::new(Mutex::new(Vec::new())), + } + } + + fn queue_response(&self, stdout: &str, stderr: &str, exit_code: i32) { + self.responses.lock().unwrap().push(MockResponse { + stdout: stdout.to_string(), + stderr: stderr.to_string(), + exit_code, + }); + } + + fn pop_response(&self) -> MockResponse { + let mut responses = self.responses.lock().unwrap(); + if responses.is_empty() { + MockResponse { + stdout: String::new(), + stderr: "no mock response queued".to_string(), + exit_code: 1, + } + } else { + responses.remove(0) + } + } + } + + #[async_trait] + impl SpriteRunner for MockSpriteRunner { + async fn run(&self, args: &[&str]) -> Result { + self.commands.lock().unwrap().push(RecordedCommand { + args: args.iter().map(|s| s.to_string()).collect(), + }); + let resp = self.pop_response(); + Ok(SpriteOutput { + stdout: resp.stdout, + stderr: resp.stderr, + exit_code: resp.exit_code, + }) + } + + async fn run_with_timeout( + &self, + args: &[&str], + _timeout: std::time::Duration, + ) -> Result { + self.commands.lock().unwrap().push(RecordedCommand { + args: args.iter().map(|s| s.to_string()).collect(), + }); + let resp = self.pop_response(); + if resp.exit_code == -99 { + return Err("Command timed out".to_string()); + } + Ok(SpriteOutput { + stdout: resp.stdout, + stderr: resp.stderr, + exit_code: resp.exit_code, + }) + } + } + + /// Helper: create a SpritesSandbox with sprite_name pre-set (skipping lifecycle). + fn sandbox_with_mock(runner: MockSpriteRunner) -> SpritesSandbox { + let config = SpritesConfig { + sprite_name: Some("test-sprite".to_string()), + ..Default::default() + }; + let sandbox = SpritesSandbox::new(Box::new(runner), config); + let _ = sandbox.sprite_name.set("test-sprite".to_string()); + sandbox + } + + // ---- Metadata ---- + + #[test] + fn working_directory_returns_home_sprite() { + let sandbox = sandbox_with_mock(MockSpriteRunner::new()); + assert_eq!(sandbox.working_directory(), "/home/sprite"); + } + + #[test] + fn platform_returns_linux() { + let sandbox = sandbox_with_mock(MockSpriteRunner::new()); + assert_eq!(sandbox.platform(), "linux"); + } + + #[test] + fn os_version_returns_linux_sprites() { + let sandbox = sandbox_with_mock(MockSpriteRunner::new()); + assert_eq!(sandbox.os_version(), "Linux (Sprites)"); + } + + #[test] + fn sandbox_info_returns_sprite_name() { + let sandbox = sandbox_with_mock(MockSpriteRunner::new()); + assert_eq!(sandbox.sandbox_info(), "test-sprite"); + } + + // ---- exec_command ---- + + #[tokio::test] + async fn exec_command_runs_via_sprite_exec() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + runner.queue_response("hello world\n", "", 0); + let sandbox = sandbox_with_mock(runner); + + let result = sandbox + .exec_command("echo hello world", 5000, None, None, None) + .await + .unwrap(); + + assert_eq!(result.stdout.trim(), "hello world"); + assert_eq!(result.exit_code, 0); + assert!(!result.timed_out); + + let recorded = commands.lock().unwrap(); + assert_eq!(recorded[0].args[0], "exec"); + assert_eq!(recorded[0].args[1], "-s"); + assert_eq!(recorded[0].args[2], "test-sprite"); + assert_eq!(recorded[0].args[3], "bash"); + assert_eq!(recorded[0].args[4], "-c"); + } + + #[tokio::test] + async fn exec_command_with_working_dir() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + runner.queue_response("", "", 0); + let sandbox = sandbox_with_mock(runner); + + sandbox + .exec_command("ls", 5000, Some("/tmp/work"), None, None) + .await + .unwrap(); + + let recorded = commands.lock().unwrap(); + let cmd = recorded[0].args.last().unwrap(); + assert!( + cmd.contains("cd '/tmp/work'"), + "expected cd to working dir, got: {cmd}", + ); + } + + #[tokio::test] + async fn exec_command_with_env_vars() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + runner.queue_response("", "", 0); + let sandbox = sandbox_with_mock(runner); + + let mut env = HashMap::new(); + env.insert("FOO".to_string(), "bar".to_string()); + + sandbox + .exec_command("echo $FOO", 5000, None, Some(&env), None) + .await + .unwrap(); + + let recorded = commands.lock().unwrap(); + let cmd = recorded[0].args.last().unwrap(); + assert!( + cmd.contains("export FOO='bar';"), + "expected env var export, got: {cmd}", + ); + } + + #[tokio::test] + async fn exec_command_timeout() { + let runner = MockSpriteRunner::new(); + runner.queue_response("", "", -99); + let sandbox = sandbox_with_mock(runner); + + let result = sandbox + .exec_command("sleep 999", 100, None, None, None) + .await + .unwrap(); + + assert!(result.timed_out); + assert_eq!(result.exit_code, -1); + } + + // ---- read_file ---- + + #[tokio::test] + async fn read_file_returns_numbered_lines() { + let runner = MockSpriteRunner::new(); + runner.queue_response("line one\nline two\nline three\n", "", 0); + let sandbox = sandbox_with_mock(runner); + + let content = sandbox.read_file("test.txt", None, None).await.unwrap(); + assert!(content.contains("1 | line one")); + assert!(content.contains("2 | line two")); + assert!(content.contains("3 | line three")); + } + + #[tokio::test] + async fn read_file_with_offset_and_limit() { + let runner = MockSpriteRunner::new(); + runner.queue_response("a\nb\nc\nd\ne\n", "", 0); + let sandbox = sandbox_with_mock(runner); + + let content = sandbox + .read_file("test.txt", Some(1), Some(2)) + .await + .unwrap(); + assert!(content.contains("2 | b")); + assert!(content.contains("3 | c")); + assert!(!content.contains("1 | a")); + assert!(!content.contains("4 | d")); + } + + #[tokio::test] + async fn read_file_absolute_path() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + runner.queue_response("content\n", "", 0); + let sandbox = sandbox_with_mock(runner); + + sandbox + .read_file("/etc/hosts", None, None) + .await + .unwrap(); + + let recorded = commands.lock().unwrap(); + let cmd = recorded[0].args.last().unwrap(); + assert!( + cmd.contains("cat '/etc/hosts'"), + "expected absolute path in cat, got: {cmd}", + ); + assert!( + !cmd.contains("/home/sprite/etc"), + "should not prepend working dir for absolute path, got: {cmd}", + ); + } + + // ---- write_file ---- + + #[tokio::test] + async fn write_file_creates_parent_and_uploads() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + // Response for mkdir -p + runner.queue_response("", "", 0); + // Response for base64 write + runner.queue_response("", "", 0); + let sandbox = sandbox_with_mock(runner); + + sandbox + .write_file("deep/nested/file.txt", "hello") + .await + .unwrap(); + + let recorded = commands.lock().unwrap(); + let mkdir_cmd = recorded[0].args.last().unwrap(); + assert!( + mkdir_cmd.contains("mkdir -p"), + "expected mkdir -p, got: {mkdir_cmd}", + ); + assert!( + mkdir_cmd.contains("/home/sprite/deep/nested"), + "expected parent path, got: {mkdir_cmd}", + ); + + let write_cmd = recorded[1].args.last().unwrap(); + assert!( + write_cmd.contains("base64 -d"), + "expected base64 decode, got: {write_cmd}", + ); + assert!( + write_cmd.contains("/home/sprite/deep/nested/file.txt"), + "expected file path, got: {write_cmd}", + ); + } + + // ---- delete_file ---- + + #[tokio::test] + async fn delete_file_runs_rm() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + runner.queue_response("", "", 0); + let sandbox = sandbox_with_mock(runner); + + sandbox.delete_file("old.txt").await.unwrap(); + + let recorded = commands.lock().unwrap(); + let cmd = recorded[0].args.last().unwrap(); + assert!( + cmd.contains("rm -f"), + "expected rm -f, got: {cmd}", + ); + } + + // ---- file_exists ---- + + #[tokio::test] + async fn file_exists_true() { + let runner = MockSpriteRunner::new(); + runner.queue_response("", "", 0); + let sandbox = sandbox_with_mock(runner); + + assert!(sandbox.file_exists("exists.txt").await.unwrap()); + } + + #[tokio::test] + async fn file_exists_false() { + let runner = MockSpriteRunner::new(); + runner.queue_response("", "", 1); + let sandbox = sandbox_with_mock(runner); + + assert!(!sandbox.file_exists("missing.txt").await.unwrap()); + } + + // ---- list_directory ---- + + #[tokio::test] + async fn list_directory_parses_find_output() { + let runner = MockSpriteRunner::new(); + runner.queue_response( + "f\t1024\tfile.txt\nd\t4096\tsrc\nf\t512\tREADME.md\n", + "", + 0, + ); + let sandbox = sandbox_with_mock(runner); + + let entries = sandbox.list_directory(".", None).await.unwrap(); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].name, "README.md"); + assert!(!entries[0].is_dir); + assert_eq!(entries[0].size, Some(512)); + assert_eq!(entries[1].name, "file.txt"); + assert_eq!(entries[2].name, "src"); + assert!(entries[2].is_dir); + assert!(entries[2].size.is_none()); + } + + // ---- grep ---- + + #[tokio::test] + async fn grep_returns_matches() { + let runner = MockSpriteRunner::new(); + // rg --version check + runner.queue_response("ripgrep 14.0.0", "", 0); + // actual grep + runner.queue_response( + "src/main.rs:1:fn main() {}\nsrc/lib.rs:5:fn helper() {}\n", + "", + 0, + ); + let sandbox = sandbox_with_mock(runner); + + let results = sandbox + .grep("fn ", ".", &GrepOptions::default()) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + assert!(results[0].contains("main.rs")); + } + + #[tokio::test] + async fn grep_no_matches_returns_empty() { + let runner = MockSpriteRunner::new(); + // rg --version + runner.queue_response("ripgrep 14.0.0", "", 0); + // grep with no matches (exit code 1) + runner.queue_response("", "", 1); + let sandbox = sandbox_with_mock(runner); + + let results = sandbox + .grep("nonexistent", ".", &GrepOptions::default()) + .await + .unwrap(); + + assert!(results.is_empty()); + } + + // ---- glob ---- + + #[tokio::test] + async fn glob_finds_files() { + let runner = MockSpriteRunner::new(); + runner.queue_response( + "/home/sprite/src/main.rs\n/home/sprite/src/lib.rs\n", + "", + 0, + ); + let sandbox = sandbox_with_mock(runner); + + let results = sandbox.glob("*.rs", Some("src")).await.unwrap(); + + assert_eq!(results.len(), 2); + assert!(results[0].contains("main.rs")); + } + + // ---- download_file_to_local ---- + + #[tokio::test] + async fn download_file_to_local_writes_bytes() { + let runner = MockSpriteRunner::new(); + use base64::Engine; + let encoded = + base64::engine::general_purpose::STANDARD.encode(b"binary content"); + runner.queue_response(&encoded, "", 0); + let sandbox = sandbox_with_mock(runner); + + let tmp = tempfile::tempdir().unwrap(); + let local = tmp.path().join("downloaded.bin"); + sandbox + .download_file_to_local("artifact.bin", &local) + .await + .unwrap(); + + let bytes = tokio::fs::read(&local).await.unwrap(); + assert_eq!(bytes, b"binary content"); + } + + // ---- initialize ---- + + #[tokio::test] + async fn initialize_creates_sprite() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + // create response + runner.queue_response("", "", 0); + // url response + runner.queue_response("https://arc-test.sprites.dev/", "", 0); + + let config = SpritesConfig::default(); + let sandbox = SpritesSandbox::new(Box::new(runner), config); + + sandbox.initialize().await.unwrap(); + + let name = sandbox.sandbox_info(); + assert!( + name.starts_with("arc-"), + "expected name to start with arc-, got: {name}", + ); + + let recorded = commands.lock().unwrap(); + assert!( + recorded[0].args.contains(&"create".to_string()), + "expected create command, got: {:?}", + recorded[0].args, + ); + } + + #[tokio::test] + async fn initialize_emits_events() { + let runner = MockSpriteRunner::new(); + runner.queue_response("", "", 0); + runner.queue_response("https://arc-test.sprites.dev/", "", 0); + + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let events_cb = Arc::clone(&events); + + let config = SpritesConfig::default(); + let mut sandbox = SpritesSandbox::new(Box::new(runner), config); + sandbox.set_event_callback(Arc::new(move |event| { + events_cb.lock().unwrap().push(format!("{event:?}")); + })); + + sandbox.initialize().await.unwrap(); + + let captured = events.lock().unwrap(); + assert!( + captured.iter().any(|e| e.contains("Initializing")), + "expected Initializing event, got: {captured:?}", + ); + assert!( + captured.iter().any(|e| e.contains("Ready")), + "expected Ready event, got: {captured:?}", + ); + } + + #[tokio::test] + async fn initialize_gets_preview_url() { + let runner = MockSpriteRunner::new(); + runner.queue_response("", "", 0); + runner.queue_response("https://my-sprite.sprites.dev/\n", "", 0); + + let config = SpritesConfig::default(); + let sandbox = SpritesSandbox::new(Box::new(runner), config); + + sandbox.initialize().await.unwrap(); + + assert_eq!( + sandbox.preview_url.get().unwrap(), + "https://my-sprite.sprites.dev/", + ); + } + + #[tokio::test] + async fn initialize_with_org() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + runner.queue_response("", "", 0); + runner.queue_response("https://arc-test-myorg.sprites.dev/", "", 0); + + let config = SpritesConfig { + org: Some("myorg".to_string()), + ..Default::default() + }; + let sandbox = SpritesSandbox::new(Box::new(runner), config); + + sandbox.initialize().await.unwrap(); + + let recorded = commands.lock().unwrap(); + assert!( + recorded[0].args.contains(&"-o".to_string()), + "expected -o in create args, got: {:?}", + recorded[0].args, + ); + assert!( + recorded[0].args.contains(&"myorg".to_string()), + "expected myorg in create args, got: {:?}", + recorded[0].args, + ); + } + + #[tokio::test] + async fn initialize_sets_url_auth() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + runner.queue_response("", "", 0); + runner.queue_response("https://arc-test.sprites.dev/", "", 0); + runner.queue_response("", "", 0); + + let config = SpritesConfig { + url_auth: Some("public".to_string()), + ..Default::default() + }; + let sandbox = SpritesSandbox::new(Box::new(runner), config); + + sandbox.initialize().await.unwrap(); + + let recorded = commands.lock().unwrap(); + assert!( + recorded[2].args.contains(&"update".to_string()), + "expected url update command, got: {:?}", + recorded[2].args, + ); + assert!( + recorded[2].args.contains(&"--auth".to_string()), + "expected --auth flag, got: {:?}", + recorded[2].args, + ); + assert!( + recorded[2].args.contains(&"public".to_string()), + "expected public auth mode, got: {:?}", + recorded[2].args, + ); + } + + #[tokio::test] + async fn initialize_reuses_existing_sprite() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + // Only url response needed (no create) + runner.queue_response("https://test-sprite.sprites.dev/", "", 0); + + let config = SpritesConfig { + sprite_name: Some("test-sprite".to_string()), + ..Default::default() + }; + let sandbox = SpritesSandbox::new(Box::new(runner), config); + + sandbox.initialize().await.unwrap(); + + assert_eq!(sandbox.sandbox_info(), "test-sprite"); + + let recorded = commands.lock().unwrap(); + assert!( + !recorded + .iter() + .any(|c| c.args.contains(&"create".to_string())), + "should not create when reusing existing sprite", + ); + } + + // ---- cleanup ---- + + #[tokio::test] + async fn cleanup_destroys_sprite() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + runner.queue_response("", "", 0); + + let config = SpritesConfig::default(); + let sandbox = SpritesSandbox::new(Box::new(runner), config); + let _ = sandbox.sprite_name.set("test-sprite".to_string()); + + sandbox.cleanup().await.unwrap(); + + let recorded = commands.lock().unwrap(); + assert!( + recorded[0].args.contains(&"destroy".to_string()), + "expected destroy command, got: {:?}", + recorded[0].args, + ); + assert!( + recorded[0].args.contains(&"test-sprite".to_string()), + "expected sprite name in destroy args, got: {:?}", + recorded[0].args, + ); + } + + #[tokio::test] + async fn cleanup_before_initialize_is_noop() { + let runner = MockSpriteRunner::new(); + let config = SpritesConfig::default(); + let sandbox = SpritesSandbox::new(Box::new(runner), config); + // Should not error — no sprite to destroy + sandbox.cleanup().await.unwrap(); + } + + #[tokio::test] + async fn cleanup_preserves_existing_sprite() { + let runner = MockSpriteRunner::new(); + let commands = runner.commands.clone(); + + let config = SpritesConfig { + sprite_name: Some("test-sprite".to_string()), + ..Default::default() + }; + let sandbox = SpritesSandbox::new(Box::new(runner), config); + let _ = sandbox.sprite_name.set("test-sprite".to_string()); + + sandbox.cleanup().await.unwrap(); + + let recorded = commands.lock().unwrap(); + assert!( + recorded.is_empty(), + "should not destroy preserved sprite, got: {recorded:?}", + ); + } +} diff --git a/crates/arc-sprites/tests/e2e.rs b/crates/arc-sprites/tests/e2e.rs new file mode 100644 index 000000000..2b3f21c41 --- /dev/null +++ b/crates/arc-sprites/tests/e2e.rs @@ -0,0 +1,197 @@ +//! E2E tests against the live Sprites service. +//! +//! Requires an authenticated `sprite` CLI. Run with: +//! cargo test -p arc-sprites --test e2e -- --ignored + +use arc_agent::sandbox::Sandbox; +use arc_sprites::{CliSpriteRunner, SpritesConfig, SpritesSandbox}; + +/// Full lifecycle test: create sprite, run operations, destroy sprite. +#[tokio::test] +#[ignore] +async fn full_lifecycle() { + let runner = CliSpriteRunner::new(); + let config = SpritesConfig::default(); + let sandbox = SpritesSandbox::new(Box::new(runner), config); + + // --- Initialize --- + sandbox.initialize().await.unwrap(); + let name = sandbox.sandbox_info(); + assert!( + name.starts_with("arc-"), + "expected sprite name starting with arc-, got: {name}", + ); + + // Wrap the rest in a closure-like block so we can always cleanup + let result = run_operations(&sandbox).await; + + // --- Cleanup (always runs) --- + sandbox.cleanup().await.unwrap(); + + // Propagate any error from operations + result.unwrap(); +} + +async fn run_operations(sandbox: &SpritesSandbox) -> Result<(), String> { + // --- Metadata --- + assert_eq!(sandbox.working_directory(), "/home/sprite"); + assert_eq!(sandbox.platform(), "linux"); + assert_eq!(sandbox.os_version(), "Linux (Sprites)"); + + // --- exec_command: basic --- + let result = sandbox + .exec_command("echo hello", 30_000, None, None, None) + .await?; + assert_eq!(result.exit_code, 0, "exec_command failed: {}", result.stderr); + assert_eq!(result.stdout.trim(), "hello"); + assert!(!result.timed_out); + + // --- exec_command: working directory --- + let result = sandbox + .exec_command("pwd", 30_000, Some("/tmp"), None, None) + .await?; + assert_eq!(result.exit_code, 0, "pwd failed: {}", result.stderr); + assert_eq!(result.stdout.trim(), "/tmp"); + + // --- exec_command: env vars --- + let mut env = std::collections::HashMap::new(); + env.insert("TEST_VAR".to_string(), "sprite_value".to_string()); + let result = sandbox + .exec_command("echo $TEST_VAR", 30_000, None, Some(&env), None) + .await?; + assert_eq!(result.exit_code, 0, "env exec failed: {}", result.stderr); + assert_eq!(result.stdout.trim(), "sprite_value"); + + // --- write_file + read_file round-trip --- + sandbox + .write_file("test-e2e/hello.txt", "Hello, Sprites!\nSecond line\n") + .await?; + + let content = sandbox + .read_file("test-e2e/hello.txt", None, None) + .await?; + assert!( + content.contains("Hello, Sprites!"), + "read_file missing content: {content}", + ); + assert!( + content.contains("1 | "), + "read_file missing line numbers: {content}", + ); + assert!( + content.contains("2 | Second line"), + "read_file missing second line: {content}", + ); + + // --- read_file with offset and limit --- + let content = sandbox + .read_file("test-e2e/hello.txt", Some(1), Some(1)) + .await?; + assert!( + content.contains("2 | Second line"), + "offset read missing line 2: {content}", + ); + assert!( + !content.contains("Hello, Sprites!"), + "offset read should skip line 1: {content}", + ); + + // --- file_exists --- + assert!( + sandbox.file_exists("test-e2e/hello.txt").await?, + "file should exist", + ); + assert!( + !sandbox.file_exists("test-e2e/nonexistent.txt").await?, + "file should not exist", + ); + + // --- delete_file --- + sandbox + .write_file("test-e2e/to-delete.txt", "delete me") + .await?; + assert!(sandbox.file_exists("test-e2e/to-delete.txt").await?); + sandbox.delete_file("test-e2e/to-delete.txt").await?; + assert!( + !sandbox.file_exists("test-e2e/to-delete.txt").await?, + "file should be deleted", + ); + + // --- list_directory --- + sandbox + .write_file("test-e2e/sub/a.txt", "aaa") + .await?; + sandbox + .write_file("test-e2e/sub/b.txt", "bbb") + .await?; + let entries = sandbox.list_directory("test-e2e/sub", None).await?; + assert_eq!(entries.len(), 2, "expected 2 entries, got: {entries:?}"); + assert_eq!(entries[0].name, "a.txt"); + assert_eq!(entries[1].name, "b.txt"); + assert!(!entries[0].is_dir); + + // --- grep --- + sandbox + .write_file("test-e2e/search/code.rs", "fn main() {\n println!(\"hello\");\n}\n") + .await?; + sandbox + .write_file("test-e2e/search/data.txt", "no match here\n") + .await?; + let grep_results = sandbox + .grep("println", "test-e2e/search", &Default::default()) + .await?; + assert_eq!( + grep_results.len(), + 1, + "expected 1 grep match, got: {grep_results:?}", + ); + assert!( + grep_results[0].contains("println"), + "grep result should contain match: {}", + grep_results[0], + ); + + // --- grep: no matches --- + let grep_results = sandbox + .grep("zzz_no_match_zzz", "test-e2e/search", &Default::default()) + .await?; + assert!( + grep_results.is_empty(), + "expected no grep matches, got: {grep_results:?}", + ); + + // --- glob --- + let glob_results = sandbox.glob("*.rs", Some("test-e2e/search")).await?; + assert_eq!( + glob_results.len(), + 1, + "expected 1 glob match, got: {glob_results:?}", + ); + assert!( + glob_results[0].contains("code.rs"), + "glob result should contain code.rs: {}", + glob_results[0], + ); + + // --- download_file_to_local --- + let download_content = "binary-like content for download test"; + sandbox + .write_file("test-e2e/download.bin", download_content) + .await?; + + let tmp = tempfile::tempdir().map_err(|e| format!("tempdir: {e}"))?; + let local_path = tmp.path().join("downloaded.bin"); + sandbox + .download_file_to_local("test-e2e/download.bin", &local_path) + .await?; + + let downloaded = tokio::fs::read_to_string(&local_path) + .await + .map_err(|e| format!("read local: {e}"))?; + assert_eq!( + downloaded, download_content, + "download content mismatch", + ); + + Ok(()) +}