diff --git a/.claude/skills/docs/references/mapping.md b/.claude/skills/docs/references/mapping.md index 1d617a89c..7be7d1c18 100644 --- a/.claude/skills/docs/references/mapping.md +++ b/.claude/skills/docs/references/mapping.md @@ -29,10 +29,8 @@ Which source files affect which doc pages. Use this as guidance — also apply j | `lib/crates/fabro-agent/src/subagent.rs` | `docs/agents/subagents.mdx` | | `lib/crates/fabro-agent/src/mcp_integration.rs` | `docs/agents/mcp.mdx` | | `lib/crates/fabro-llm/src/catalog.rs`, `lib/crates/fabro-llm/src/providers/*.rs` | `docs/core-concepts/models.mdx` | -| `lib/crates/fabro-exe/src/*.rs` | `docs/integrations/exe-dev.mdx`, `docs/execution/environments.mdx` | | `lib/crates/fabro-devcontainer/src/*.rs` | `docs/execution/devcontainers.mdx` | | `lib/crates/fabro-slack/src/*.rs` | `docs/integrations/slack.mdx` | -| `lib/crates/fabro-sprites/src/*.rs` | `docs/integrations/sprites.mdx` | | `lib/crates/fabro-mcp/src/*.rs` | `docs/agents/mcp.mdx` | | `lib/crates/fabro-api/src/*.rs` | `docs/api-reference/overview.mdx`, `docs/api-reference/demo-mode.mdx` | | `lib/crates/fabro-api/src/server_config.rs` | `docs/administration/server-configuration.mdx` | diff --git a/docs/changelog/2026-03-07.mdx b/docs/changelog/2026-03-07.mdx index 5200968b8..c6a3a4d1c 100644 --- a/docs/changelog/2026-03-07.mdx +++ b/docs/changelog/2026-03-07.mdx @@ -18,11 +18,11 @@ fabro llm chat --server-url http://localhost:3000 Fabro now supports three sandbox environments for running agent stages. In addition to Daytona, you can now use **exe.dev** VMs or **Sprites** (powered by Fly.io). -exe.dev (beta) manages ephemeral VMs through SSH with separate management and data plane connections. Sprites uses the `sprite` CLI to provision Fly.io VMs with full sandbox lifecycle support. +Daytona provides cloud sandbox VMs with full lifecycle support. ```toml title="run.toml" [execution] -environment = "exe" # or "sprites" or "daytona" +environment = "daytona" ``` ## Sandbox environment variables diff --git a/docs/core-concepts/how-fabro-works.mdx b/docs/core-concepts/how-fabro-works.mdx index 0bd1bc949..1b2ea8f21 100644 --- a/docs/core-concepts/how-fabro-works.mdx +++ b/docs/core-concepts/how-fabro-works.mdx @@ -74,9 +74,6 @@ Node handlers execute tools (bash commands, file edits) inside a **sandbox**. Fa | `local` | Tools run directly on the host machine (default) | | `docker` | Tools run inside a Docker container | | `daytona` | Tools run in a cloud VM with SSH access | -| `ssh` | Tools run on any existing SSH host | -| `exe` | Tools run in a fast ephemeral VM from [exe.dev](https://exe.dev) | -| `sprites` | Tools run in a persistent VM from [Sprites](https://sprites.dev) | The sandbox is configured per-run via CLI flags (`--sandbox docker`) or the run config TOML. See [Environments](/execution/environments) for details on each provider. diff --git a/docs/docs.json b/docs/docs.json index c98621aab..2f3535bf7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -94,7 +94,6 @@ "integrations/github", "integrations/daytona", "integrations/slack", - "integrations/sprites", "integrations/brave-search" ] }, diff --git a/docs/integrations/sprites.mdx b/docs/integrations/sprites.mdx deleted file mode 100644 index b4d72767f..000000000 --- a/docs/integrations/sprites.mdx +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: "Sprites (preview)" -description: "Run Fabro workflows in persistent Sprites VMs from Fly.io" ---- - - -The Sprites sandbox provider is **in progress** and not yet available for use. This page describes the planned integration. We'll update it when Sprites support ships. - - -[Sprites](https://sprites.dev) (by Fly.io) provides persistent, hardware-isolated Linux microVMs for Fabro workflows. Unlike ephemeral providers, Sprites VMs persist across runs with auto-sleep/wake — so dependencies stay installed and caches stay warm. - -## How it works - -- Each run creates (or reuses) a Sprite VM via the `sprite` CLI -- Agent tool calls (shell commands, file edits, grep, glob) execute via `sprite exec` inside the VM -- The working directory is `/home/sprite` -- Each Sprite has a preview URL (e.g. `https://-.sprites.app/`) -- VMs auto-sleep after inactivity and wake on the next command -- Pre-installed toolchains include ripgrep - -## Configuration - -```bash -fabro run workflow.fabro --sandbox sprites -``` - -```toml title="run.toml" -[sandbox] -provider = "sprites" -``` - -A full configuration example with all Sprites-specific options: - -```toml title="run.toml" -[sandbox] -provider = "sprites" - -[sandbox.sprites] -org = "my-org" -url_auth = "public" -sprite_name = "my-existing-sprite" -``` - -| Option | Description | -|---|---| -| `org` | Sprites organization name (passed as `--org` to the CLI) | -| `url_auth` | Preview URL auth mode: `"public"` or `"sprite"` (default) | -| `sprite_name` | Reuse an existing Sprite instead of creating a new one. Skips create and destroy — useful for development | - -## Prerequisites - -- The `sprite` CLI installed and authenticated (`sprite login`) -- A `SPRITE_TOKEN` environment variable (or interactive login) - -## Reusing Sprites - -Set `sprite_name` to reuse an existing Sprite across runs. When set, Fabro skips creation and destruction — the Sprite persists between runs with its filesystem intact: - -```toml title="run.toml" -[sandbox.sprites] -sprite_name = "my-dev-sprite" -``` - -This is useful for development workflows where you want to keep installed dependencies and project state between runs. diff --git a/docs/reference/sdk.mdx b/docs/reference/sdk.mdx index a38a9ebed..50771c9ef 100644 --- a/docs/reference/sdk.mdx +++ b/docs/reference/sdk.mdx @@ -155,7 +155,7 @@ pub trait Sandbox: Send + Sync { | `DockerSandbox` | Runs inside a Docker container (feature-gated: `docker`). | | `ReadBeforeWriteSandbox` | Decorator that blocks writes to files the agent hasn't read. Wraps any `Arc`. | -External crates provide additional implementations: `ExeSandbox` (SSH-based) in `fabro-exe`, and `SpritesSandbox` in `fabro-sprites`. +The `DaytonaSandbox` implementation (feature-gated: `daytona`) runs inside a Daytona cloud sandbox. ### Provider profiles diff --git a/lib/crates/fabro-sandbox/Cargo.toml b/lib/crates/fabro-sandbox/Cargo.toml index 662de4ef4..1457ebcec 100644 --- a/lib/crates/fabro-sandbox/Cargo.toml +++ b/lib/crates/fabro-sandbox/Cargo.toml @@ -10,7 +10,6 @@ description = "Sandbox trait and implementations for Fabro agent execution envir default = ["local"] local = [] docker = ["dep:bollard", "dep:tar", "dep:futures"] -sprites = ["dep:chrono", "dep:rand"] daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-github", "dep:fabro-config", "dep:chrono", "dep:rand"] test-support = [] @@ -44,11 +43,9 @@ fabro-config = { path = "../fabro-config", optional = true } fabro-github = { path = "../fabro-github", optional = true } fabro-types = { path = "../fabro-types" } -# sprites +# daytona chrono = { workspace = true, optional = true } rand = { workspace = true, optional = true } - -# daytona daytona-sdk = { workspace = true, optional = true } daytona-api-client = { workspace = true, optional = true } git2 = { workspace = true, optional = true } diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index fddcaf92d..4cd3d9d7f 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -16,9 +16,6 @@ pub mod local; #[cfg(feature = "docker")] pub mod docker; -#[cfg(feature = "sprites")] -pub mod sprites; - #[cfg(feature = "daytona")] pub mod daytona; diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs index 0688f7561..2e638354f 100644 --- a/lib/crates/fabro-sandbox/src/sandbox.rs +++ b/lib/crates/fabro-sandbox/src/sandbox.rs @@ -501,8 +501,8 @@ pub trait Sandbox: Send + Sync { } /// Resolve a path: relative paths are prepended with the working directory. -/// Used by feature-gated sandbox implementations (exe, ssh, sprites, daytona). -#[cfg(any(feature = "sprites", feature = "daytona"))] +/// Used by the Daytona sandbox implementation. +#[cfg(feature = "daytona")] pub(crate) fn resolve_path(path: &str, working_dir: &str) -> String { if std::path::Path::new(path).is_absolute() { path.to_string() diff --git a/lib/crates/fabro-sandbox/src/sprites/cli_runner.rs b/lib/crates/fabro-sandbox/src/sprites/cli_runner.rs deleted file mode 100644 index 1a4b0685b..000000000 --- a/lib/crates/fabro-sandbox/src/sprites/cli_runner.rs +++ /dev/null @@ -1,41 +0,0 @@ -use async_trait::async_trait; - -use super::{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/lib/crates/fabro-sandbox/src/sprites/mod.rs b/lib/crates/fabro-sandbox/src/sprites/mod.rs deleted file mode 100644 index 0428b2d2c..000000000 --- a/lib/crates/fabro-sandbox/src/sprites/mod.rs +++ /dev/null @@ -1,1256 +0,0 @@ -mod cli_runner; - -use std::collections::HashMap; -use std::path::Path; -use std::time::Instant; - -use crate::{ - DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, - format_lines_numbered, -}; -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use tokio_util::sync::CancellationToken; - -pub use cli_runner::CliSpriteRunner; - -use crate::shell_quote; - -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, -} - -impl SpritesSandbox { - pub fn new(runner: Box, config: SpritesConfig) -> Self { - 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, - } - } - - 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 { - crate::sandbox::resolve_path(path, WORKING_DIRECTORY) - } - - 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("--".to_string()); - 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!("fabro-{}-{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.config.sprite_name.is_none() { - 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 {}={};", - shell_quote(key), - shell_quote(value) - )); - } - } - - if let Some(dir) = working_dir { - let resolved = self.resolve_path(dir); - parts.push(format!("cd {}", shell_quote(&resolved))); - parts.push("&&".to_string()); - } else { - parts.push(format!("cd {}", shell_quote(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 {}", shell_quote(&resolved)); - - 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 {}", shell_quote(&parent_str)); - 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, - shell_quote(&resolved), - ); - - 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 {}", shell_quote(&resolved)); - - 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 {}", shell_quote(&resolved)); - - 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'", - shell_quote(&resolved), - 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 {}", shell_quote(glob_filter))); - } - if let Some(max) = options.max_results { - cmd.push_str(&format!(" --max-count {max}")); - } - cmd.push_str(&format!( - " -- {} {}", - shell_quote(pattern), - shell_quote(&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 {}", shell_quote(glob_filter))); - } - if let Some(max) = options.max_results { - cmd.push_str(&format!(" -m {max}")); - } - cmd.push_str(&format!( - " -- {} {}", - shell_quote(pattern), - shell_quote(&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", - shell_quote(&base), - shell_quote(pattern), - ); - - 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 {}", shell_quote(&resolved)); - - 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(()) - } - - async fn upload_file_from_local( - &self, - local_path: &Path, - remote_path: &str, - ) -> Result<(), String> { - let resolved = self.resolve_path(remote_path); - - let bytes = tokio::fs::read(local_path) - .await - .map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?; - - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); - - // Ensure parent directory exists - if let Some(parent) = Path::new(&resolved).parent() { - let mkdir_cmd = format!("mkdir -p {}", shell_quote(&parent.to_string_lossy())); - self.exec_command(&mkdir_cmd, 10_000, None, None, None) - .await?; - } - - let cmd = format!( - "echo {} | base64 -d > {}", - shell_quote(&encoded), - shell_quote(&resolved) - ); - 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(()) - } - - 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], "--"); - assert_eq!(recorded[0].args[4], "bash"); - assert_eq!(recorded[0].args[5], "-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("fabro-"), - "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:?}", - ); - } -}