diff --git a/Cargo.lock b/Cargo.lock index 92687d0f6..a2a3ba951 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1241,7 +1241,6 @@ version = "0.176.2" dependencies = [ "anyhow", "async-trait", - "bollard", "chrono", "clap", "dirs", @@ -1249,6 +1248,7 @@ dependencies = [ "fabro-config", "fabro-llm", "fabro-mcp", + "fabro-sandbox", "fabro-util", "futures", "glob", @@ -1260,7 +1260,6 @@ dependencies = [ "serde", "serde_json", "shell-escape", - "tar", "tempfile", "thiserror 2.0.18", "tokio", @@ -1282,15 +1281,14 @@ dependencies = [ "dirs", "fabro-agent", "fabro-config", - "fabro-daytona", "fabro-db", - "fabro-exe", "fabro-github", "fabro-graphviz", "fabro-hooks", "fabro-interview", "fabro-llm", "fabro-retro", + "fabro-sandbox", "fabro-types", "fabro-util", "fabro-workflows", @@ -1354,9 +1352,7 @@ dependencies = [ "fabro-api", "fabro-beastie", "fabro-config", - "fabro-daytona", "fabro-devcontainer", - "fabro-exe", "fabro-git-storage", "fabro-github", "fabro-graphviz", @@ -1366,7 +1362,7 @@ dependencies = [ "fabro-mcp", "fabro-openai-oauth", "fabro-retro", - "fabro-ssh", + "fabro-sandbox", "fabro-telemetry", "fabro-util", "fabro-validate", @@ -1420,29 +1416,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "fabro-daytona" -version = "0.176.2" -dependencies = [ - "async-trait", - "base64", - "chrono", - "daytona-api-client", - "daytona-sdk", - "fabro-agent", - "fabro-config", - "fabro-github", - "git2", - "rand 0.8.5", - "serde", - "shlex", - "tempfile", - "tokio", - "tokio-util", - "toml", - "tracing", -] - [[package]] name = "fabro-db" version = "0.176.2" @@ -1470,26 +1443,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "fabro-exe" -version = "0.176.2" -dependencies = [ - "async-trait", - "base64", - "fabro-agent", - "fabro-config", - "fabro-github", - "openssh", - "reqwest 0.12.28", - "serde", - "serde_json", - "shlex", - "tempfile", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "fabro-git-storage" version = "0.176.2" @@ -1652,6 +1605,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "fabro-sandbox" +version = "0.176.2" +dependencies = [ + "async-trait", + "base64", + "bollard", + "chrono", + "daytona-api-client", + "daytona-sdk", + "fabro-config", + "fabro-github", + "futures", + "git2", + "glob", + "libc", + "openssh", + "rand 0.8.5", + "serde", + "serde_json", + "shlex", + "tar", + "tempfile", + "tokio", + "tokio-util", + "toml", + "tracing", + "uuid", +] + [[package]] name = "fabro-slack" version = "0.176.2" @@ -1671,42 +1654,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "fabro-sprites" -version = "0.176.2" -dependencies = [ - "async-trait", - "base64", - "chrono", - "fabro-agent", - "rand 0.8.5", - "serde", - "shlex", - "tempfile", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "fabro-ssh" -version = "0.176.2" -dependencies = [ - "async-trait", - "base64", - "fabro-agent", - "fabro-config", - "fabro-github", - "openssh", - "serde", - "serde_json", - "shlex", - "tempfile", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "fabro-telemetry" version = "0.176.2" @@ -1796,9 +1743,7 @@ dependencies = [ "dotenvy", "fabro-agent", "fabro-config", - "fabro-daytona", "fabro-devcontainer", - "fabro-exe", "fabro-git-storage", "fabro-github", "fabro-graphviz", @@ -1807,7 +1752,7 @@ dependencies = [ "fabro-llm", "fabro-mcp", "fabro-retro", - "fabro-ssh", + "fabro-sandbox", "fabro-util", "fabro-validate", "futures", diff --git a/lib/crates/fabro-agent/Cargo.toml b/lib/crates/fabro-agent/Cargo.toml index 34fa58ed0..8f23c6819 100644 --- a/lib/crates/fabro-agent/Cargo.toml +++ b/lib/crates/fabro-agent/Cargo.toml @@ -11,7 +11,7 @@ categories = ["api-bindings"] [features] default = ["docker"] -docker = ["bollard", "tar"] +docker = ["fabro-sandbox/docker"] quarantine = [] [lib] @@ -24,6 +24,7 @@ dotenvy.workspace = true fabro-config = { path = "../fabro-config", features = ["clap"] } fabro-llm = { path = "../fabro-llm" } fabro-mcp = { path = "../fabro-mcp" } +fabro-sandbox = { path = "../fabro-sandbox" } fabro-util = { path = "../fabro-util" } thiserror.workspace = true serde.workspace = true @@ -41,8 +42,6 @@ dirs = "6" glob = "0.3" shell-escape = "0.1" htmd = "0.5" -bollard = { workspace = true, optional = true } -tar = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -52,3 +51,4 @@ tokio = { workspace = true, features = ["test-util", "macros"] } tempfile = "3" dotenvy = { workspace = true } paste = "1" +fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } diff --git a/lib/crates/fabro-agent/src/docker_sandbox.rs b/lib/crates/fabro-agent/src/docker_sandbox.rs index 6f66b928a..d7635f528 100644 --- a/lib/crates/fabro-agent/src/docker_sandbox.rs +++ b/lib/crates/fabro-agent/src/docker_sandbox.rs @@ -1,959 +1,2 @@ -use crate::sandbox::{ - format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, - SandboxEventCallback, -}; -use async_trait::async_trait; -use bollard::container::{ - Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions, - StopContainerOptions, UploadToContainerOptions, -}; -use bollard::exec::{CreateExecOptions, StartExecResults}; -use bollard::image::CreateImageOptions; -use bollard::Docker; -use futures::StreamExt; -use std::collections::HashMap; -use std::time::Instant; -use tokio_util::sync::CancellationToken; - -/// Configuration for a Docker-based sandbox. -pub struct DockerSandboxConfig { - /// Docker image to use. Default: `"fabro-agent:latest"`. - pub image: String, - /// Host directory to bind-mount into the container. - pub host_working_directory: String, - /// Mount point inside the container. Default: `"/workspace"`. - pub container_mount_point: String, - /// Docker network mode. Default: `Some("bridge")`. - pub network_mode: Option, - /// Additional `"host_path:container_path"` bind mounts. - pub extra_mounts: Vec, - /// Memory limit in bytes. `None` = unlimited. - pub memory_limit: Option, - /// CPU quota (microseconds per 100ms period). `None` = unlimited. - pub cpu_quota: Option, - /// Whether to pull the image if not found locally. Default: `true`. - pub auto_pull: bool, - /// Additional `KEY=VALUE` environment variables for the container. - pub env_vars: Vec, -} - -impl Default for DockerSandboxConfig { - fn default() -> Self { - Self { - image: "fabro-agent:latest".to_string(), - host_working_directory: String::new(), - container_mount_point: "/workspace".to_string(), - network_mode: Some("bridge".to_string()), - extra_mounts: Vec::new(), - memory_limit: None, - cpu_quota: None, - auto_pull: true, - env_vars: Vec::new(), - } - } -} - -/// Sandbox that runs all operations inside a Docker container. -/// -/// The host working directory is bind-mounted at `container_mount_point`. All file -/// operations, commands, grep, and glob execute inside the container via `docker exec`. -pub struct DockerSandbox { - docker: Docker, - config: DockerSandboxConfig, - container_id: tokio::sync::OnceCell, - cached_platform: std::sync::OnceLock, - cached_os_version: std::sync::OnceLock, - rg_available: tokio::sync::OnceCell, - event_callback: Option, -} - -impl DockerSandbox { - /// Creates a new `DockerSandbox`. - /// - /// Validates Docker daemon connectivity but does NOT create a container. - /// Call `initialize()` to create and start the container. - pub fn new(config: DockerSandboxConfig) -> Result { - let docker = Docker::connect_with_local_defaults() - .map_err(|e| format!("Failed to connect to Docker daemon: {e}"))?; - Ok(Self { - docker, - config, - container_id: tokio::sync::OnceCell::new(), - cached_platform: std::sync::OnceLock::new(), - cached_os_version: std::sync::OnceLock::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 container_id(&self) -> Result<&str, String> { - self.container_id - .get() - .map(String::as_str) - .ok_or_else(|| "Container not initialized — call initialize() first".to_string()) - } - - /// Resolves a path for use inside the container. - /// Absolute paths are used as-is; relative paths are prepended with the mount point. - fn resolve_container_path(&self, path: &str) -> String { - if path.starts_with('/') { - path.to_string() - } else { - format!("{}/{path}", self.config.container_mount_point) - } - } - - /// Maps a container-space path to the corresponding host-space path - /// using the bind-mount configuration. - fn container_to_host_path(&self, remote_path: &str) -> Result { - let container_path = self.resolve_container_path(remote_path); - if container_path.starts_with(&self.config.container_mount_point) { - let relative = &container_path[self.config.container_mount_point.len()..]; - let relative = relative.strip_prefix('/').unwrap_or(relative); - Ok(std::path::PathBuf::from(&self.config.host_working_directory).join(relative)) - } else { - Err(format!( - "Path {container_path} is outside the bind-mounted directory {}", - self.config.container_mount_point - )) - } - } - - /// Executes a command inside the container, returning `(stdout, stderr, exit_code)`. - async fn docker_exec( - &self, - cmd: Vec, - working_dir: Option<&str>, - env: Option>, - ) -> Result<(String, String, i32), String> { - let container_id = self.container_id()?; - - let exec_opts = CreateExecOptions { - cmd: Some(cmd), - attach_stdout: Some(true), - attach_stderr: Some(true), - working_dir: working_dir.map(ToString::to_string), - env: env.map(|e| e.into_iter().collect()), - ..Default::default() - }; - - let exec_instance = self - .docker - .create_exec(container_id, exec_opts) - .await - .map_err(|e| format!("Failed to create exec: {e}"))?; - - let start_result = self - .docker - .start_exec(&exec_instance.id, None) - .await - .map_err(|e| format!("Failed to start exec: {e}"))?; - - let mut stdout = String::new(); - let mut stderr = String::new(); - - if let StartExecResults::Attached { mut output, .. } = start_result { - while let Some(chunk) = output.next().await { - match chunk { - Ok(bollard::container::LogOutput::StdOut { message }) => { - stdout.push_str(&String::from_utf8_lossy(&message)); - } - Ok(bollard::container::LogOutput::StdErr { message }) => { - stderr.push_str(&String::from_utf8_lossy(&message)); - } - Ok(_) => {} - Err(e) => return Err(format!("Error reading exec output: {e}")), - } - } - } - - let inspect = self - .docker - .inspect_exec(&exec_instance.id) - .await - .map_err(|e| format!("Failed to inspect exec: {e}"))?; - - let exit_code = inspect.exit_code.unwrap_or(-1) as i32; - Ok((stdout, stderr, exit_code)) - } - - /// Runs a shell command inside the container with timeout and cancellation support. - async fn docker_exec_shell( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&HashMap>, - cancel_token: Option, - ) -> Result { - let start = Instant::now(); - - let effective_dir = working_dir.map_or_else( - || self.config.container_mount_point.clone(), - ToString::to_string, - ); - - let env: Option> = - env_vars.map(|vars| vars.iter().map(|(k, v)| format!("{k}={v}")).collect()); - - let cmd = vec![ - "/bin/bash".to_string(), - "-c".to_string(), - command.to_string(), - ]; - - let timeout_duration = std::time::Duration::from_millis(timeout_ms); - let token = cancel_token.unwrap_or_default(); - - tokio::select! { - result = self.docker_exec(cmd, Some(&effective_dir), env) => { - let (stdout, stderr, exit_code) = result?; - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - Ok(ExecResult { - stdout, - stderr, - exit_code, - timed_out: false, - duration_ms, - }) - } - () = tokio::time::sleep(timeout_duration) => { - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - Ok(ExecResult { - stdout: String::new(), - stderr: "Command timed out".to_string(), - exit_code: -1, - timed_out: true, - duration_ms, - }) - } - () = token.cancelled() => { - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - Ok(ExecResult { - stdout: String::new(), - stderr: "Command cancelled".to_string(), - exit_code: -1, - timed_out: true, - duration_ms, - }) - } - } - } - - /// Pulls the configured image if `auto_pull` is enabled and the image is not found locally. - async fn ensure_image(&self) -> Result<(), String> { - if !self.config.auto_pull { - return Ok(()); - } - - // Check if image exists locally - if self.docker.inspect_image(&self.config.image).await.is_ok() { - return Ok(()); - } - - // Parse image into repo and tag - let (repo, tag) = if let Some((r, t)) = self.config.image.rsplit_once(':') { - (r.to_string(), t.to_string()) - } else { - (self.config.image.clone(), "latest".to_string()) - }; - - let opts = CreateImageOptions { - from_image: repo, - tag, - ..Default::default() - }; - - let mut stream = self.docker.create_image(Some(opts), None, None); - while let Some(result) = stream.next().await { - result.map_err(|e| format!("Failed to pull image {}: {e}", self.config.image))?; - } - - Ok(()) - } -} - -#[async_trait] -impl Sandbox for DockerSandbox { - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &std::path::Path, - ) -> Result<(), String> { - let host_path = self.container_to_host_path(remote_path)?; - - 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::copy(&host_path, local_path).await.map_err(|e| { - format!( - "Failed to copy {} to {}: {e}", - host_path.display(), - local_path.display() - ) - })?; - Ok(()) - } - - async fn upload_file_from_local( - &self, - local_path: &std::path::Path, - remote_path: &str, - ) -> Result<(), String> { - let host_path = self.container_to_host_path(remote_path)?; - - if let Some(parent) = host_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| format!("Failed to create parent dirs: {e}"))?; - } - tokio::fs::copy(local_path, &host_path).await.map_err(|e| { - format!( - "Failed to copy {} to {}: {e}", - local_path.display(), - host_path.display() - ) - })?; - Ok(()) - } - - async fn initialize(&self) -> Result<(), String> { - self.emit(SandboxEvent::Initializing { - provider: "docker".into(), - }); - let init_start = Instant::now(); - - self.emit(SandboxEvent::SnapshotPulling { - name: self.config.image.clone(), - }); - let pull_start = Instant::now(); - if let Err(e) = self.ensure_image().await { - let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::InitializeFailed { - provider: "docker".into(), - error: e.clone(), - duration_ms, - }); - return Err(e); - } - let pull_duration = u64::try_from(pull_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::SnapshotPulled { - name: self.config.image.clone(), - duration_ms: pull_duration, - }); - - let mut binds = vec![format!( - "{}:{}", - self.config.host_working_directory, self.config.container_mount_point - )]; - for extra in &self.config.extra_mounts { - binds.push(extra.clone()); - } - - let host_config = bollard::models::HostConfig { - binds: Some(binds), - network_mode: self.config.network_mode.clone(), - memory: self.config.memory_limit, - cpu_quota: self.config.cpu_quota, - ..Default::default() - }; - - let container_config = Config { - image: Some(self.config.image.clone()), - cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]), - working_dir: Some(self.config.container_mount_point.clone()), - env: if self.config.env_vars.is_empty() { - None - } else { - Some(self.config.env_vars.clone()) - }, - host_config: Some(host_config), - ..Default::default() - }; - - let container = self - .docker - .create_container(None::>, container_config) - .await - .map_err(|e| format!("Failed to create container: {e}"))?; - - let id = container.id.clone(); - - self.docker - .start_container(&id, None::>) - .await - .map_err(|e| format!("Failed to start container: {e}"))?; - - self.container_id - .set(id) - .map_err(|_| "Container already initialized".to_string())?; - - // Verify container is running - let (stdout, _, exit_code) = self - .docker_exec(vec!["echo".to_string(), "ready".to_string()], None, None) - .await?; - - if exit_code != 0 || !stdout.contains("ready") { - return Err("Container health check failed".to_string()); - } - - // Cache platform info - let (uname_output, _, _) = self - .docker_exec(vec!["uname".to_string(), "-r".to_string()], None, None) - .await?; - - let _ = self.cached_platform.set("linux".to_string()); - let _ = self - .cached_os_version - .set(format!("linux {}", uname_output.trim())); - - let init_duration = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::Ready { - provider: "docker".into(), - duration_ms: init_duration, - name: None, - cpu: None, - memory: None, - url: None, - }); - - Ok(()) - } - - async fn cleanup(&self) -> Result<(), String> { - self.emit(SandboxEvent::CleanupStarted { - provider: "docker".into(), - }); - let start = Instant::now(); - - let container_id = match self.container_id.get() { - Some(id) => id.clone(), - None => { - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::CleanupCompleted { - provider: "docker".into(), - duration_ms, - }); - return Ok(()); - } - }; - - // Stop with 5-second grace period; ignore "not running" errors - let stop_opts = StopContainerOptions { t: 5 }; - let _ = self - .docker - .stop_container(&container_id, Some(stop_opts)) - .await; - - // Force-remove; ignore "no such container" errors - let remove_opts = RemoveContainerOptions { - force: true, - ..Default::default() - }; - let _ = self - .docker - .remove_container(&container_id, Some(remove_opts)) - .await; - - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::CleanupCompleted { - provider: "docker".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 dir = working_dir.map(|d| self.resolve_container_path(d)); - self.docker_exec_shell(command, timeout_ms, dir.as_deref(), env_vars, cancel_token) - .await - } - - async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> Result { - let container_path = self.resolve_container_path(path); - let (stdout, stderr, exit_code) = self - .docker_exec(vec!["cat".to_string(), container_path.clone()], None, None) - .await?; - - if exit_code != 0 { - return Err(format!("Failed to read {container_path}: {stderr}")); - } - - Ok(format_lines_numbered(&stdout, offset, limit)) - } - - async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { - let container_path = self.resolve_container_path(path); - let container_id = self.container_id()?; - - // Ensure parent directory exists - if let Some(parent) = std::path::Path::new(&container_path).parent() { - let parent_str = parent.to_string_lossy(); - let (_, stderr, exit_code) = self - .docker_exec( - vec![ - "mkdir".to_string(), - "-p".to_string(), - parent_str.to_string(), - ], - None, - None, - ) - .await?; - if exit_code != 0 { - return Err(format!( - "Failed to create parent dirs for {container_path}: {stderr}" - )); - } - } - - // Build an in-memory tar archive to upload via bollard API. - // This avoids shell escaping issues with special characters in content. - let mut tar_builder = tar::Builder::new(Vec::new()); - let file_name = std::path::Path::new(&container_path) - .file_name() - .ok_or_else(|| format!("Invalid path: {container_path}"))? - .to_string_lossy() - .to_string(); - - let content_bytes = content.as_bytes(); - let mut header = tar::Header::new_gnu(); - header - .set_path(&file_name) - .map_err(|e| format!("Failed to set tar path: {e}"))?; - header.set_size(content_bytes.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - - tar_builder - .append(&header, content_bytes) - .map_err(|e| format!("Failed to build tar archive: {e}"))?; - - let tar_bytes = tar_builder - .into_inner() - .map_err(|e| format!("Failed to finalize tar archive: {e}"))?; - - let parent_dir = std::path::Path::new(&container_path) - .parent() - .map_or_else(|| "/".to_string(), |p| p.to_string_lossy().to_string()); - - let upload_opts = UploadToContainerOptions { - path: parent_dir, - ..Default::default() - }; - - self.docker - .upload_to_container(container_id, Some(upload_opts), tar_bytes.into()) - .await - .map_err(|e| format!("Failed to upload file to container: {e}")) - } - - async fn delete_file(&self, path: &str) -> Result<(), String> { - let container_path = self.resolve_container_path(path); - let (_, stderr, exit_code) = self - .docker_exec( - vec!["rm".to_string(), "-f".to_string(), container_path.clone()], - None, - None, - ) - .await?; - - if exit_code != 0 { - return Err(format!("Failed to delete {container_path}: {stderr}")); - } - Ok(()) - } - - async fn file_exists(&self, path: &str) -> Result { - let container_path = self.resolve_container_path(path); - let (_, _, exit_code) = self - .docker_exec( - vec!["test".to_string(), "-e".to_string(), container_path], - None, - None, - ) - .await?; - - Ok(exit_code == 0) - } - - async fn list_directory( - &self, - path: &str, - depth: Option, - ) -> Result, String> { - let container_path = self.resolve_container_path(path); - let max_depth = depth.unwrap_or(1); - - // Use find with -printf for structured output: type, size, relative path - let (stdout, stderr, exit_code) = self - .docker_exec( - vec![ - "find".to_string(), - container_path.clone(), - "-mindepth".to_string(), - "1".to_string(), - "-maxdepth".to_string(), - max_depth.to_string(), - "-printf".to_string(), - "%y\t%s\t%P\n".to_string(), - ], - None, - None, - ) - .await?; - - if exit_code != 0 { - return Err(format!( - "Failed to list directory {container_path}: {stderr}" - )); - } - - let mut entries: Vec = 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 container_path = self.resolve_container_path(path); - - // Detect ripgrep availability (cached) - let use_rg = *self - .rg_available - .get_or_init(|| async { - let result = self - .docker_exec(vec!["which".to_string(), "rg".to_string()], None, None) - .await; - matches!(result, Ok((_, _, 0))) - }) - .await; - - let command = if use_rg { - let mut args = vec!["rg".to_string(), "-n".to_string()]; - if options.case_insensitive { - args.push("-i".to_string()); - } - if let Some(ref glob_filter) = options.glob_filter { - args.push("--glob".to_string()); - args.push(glob_filter.clone()); - } - if let Some(max) = options.max_results { - args.push("-m".to_string()); - args.push(max.to_string()); - } - args.push(pattern.to_string()); - args.push(container_path); - args.join(" ") - } else { - let mut args = vec!["grep".to_string(), "-rn".to_string()]; - if options.case_insensitive { - args.push("-i".to_string()); - } - if let Some(ref glob_filter) = options.glob_filter { - args.push("--include".to_string()); - args.push(glob_filter.clone()); - } - if let Some(max) = options.max_results { - args.push("-m".to_string()); - args.push(max.to_string()); - } - args.push(format!("'{pattern}'")); - args.push(container_path); - args.join(" ") - }; - - // Run through shell so that quoting works correctly - let result = self - .docker_exec_shell(&command, 30_000, None, None, None) - .await?; - - let results: Vec = result - .stdout - .lines() - .map(String::from) - .filter(|l| !l.is_empty()) - .collect(); - - Ok(results) - } - - async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { - let base_dir = path.map_or_else( - || self.config.container_mount_point.clone(), - |p| self.resolve_container_path(p), - ); - - let full_pattern = if pattern.starts_with('/') { - pattern.to_string() - } else { - format!("{base_dir}/{pattern}") - }; - - // Use bash globbing with stat for mtime-descending sort - let script = format!( - "shopt -s nullglob globstar; for f in {full_pattern}; do stat --format='%Y %n' \"$f\" 2>/dev/null; done | sort -rn | cut -d' ' -f2-" - ); - - let result = self - .docker_exec_shell(&script, 30_000, None, None, None) - .await?; - - let results: Vec = result - .stdout - .lines() - .map(String::from) - .filter(|l| !l.is_empty()) - .collect(); - - Ok(results) - } - - fn working_directory(&self) -> &str { - &self.config.container_mount_point - } - - fn platform(&self) -> &str { - self.cached_platform.get().map_or("linux", String::as_str) - } - - fn os_version(&self) -> String { - self.cached_os_version - .get() - .cloned() - .unwrap_or_else(|| "linux".to_string()) - } - - fn sandbox_info(&self) -> String { - self.container_id.get().cloned().unwrap_or_default() - } -} - -#[cfg(test)] -#[cfg(feature = "docker")] -mod tests { - use super::*; - use std::sync::Arc; - - fn require_docker() -> Docker { - Docker::connect_with_local_defaults().expect("Docker not available — skipping") - } - - fn test_config(host_dir: &str) -> DockerSandboxConfig { - DockerSandboxConfig { - host_working_directory: host_dir.to_string(), - auto_pull: false, - ..Default::default() - } - } - - #[tokio::test] - #[ignore] - async fn full_lifecycle() { - let _docker = require_docker(); - let host_dir = - std::env::temp_dir().join(format!("docker_env_test_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&host_dir).unwrap(); - - let config = test_config(host_dir.to_str().unwrap()); - let env: Arc = Arc::new(DockerSandbox::new(config).unwrap()); - - // Initialize - env.initialize().await.unwrap(); - - // Platform and OS version - assert_eq!(env.platform(), "linux"); - assert!(env.os_version().starts_with("linux ")); - - // exec_command - let result = env - .exec_command("echo hello", 5000, None, None, None) - .await - .unwrap(); - assert_eq!(result.stdout.trim(), "hello"); - assert_eq!(result.exit_code, 0); - assert!(!result.timed_out); - - // write_file + read_file - env.write_file("test.txt", "line1\nline2\nline3") - .await - .unwrap(); - let content = env.read_file("test.txt", None, None).await.unwrap(); - assert!(content.contains("1 | line1")); - assert!(content.contains("2 | line2")); - assert!(content.contains("3 | line3")); - - // file_exists - assert!(env.file_exists("test.txt").await.unwrap()); - assert!(!env.file_exists("nonexistent.txt").await.unwrap()); - - // list_directory - let entries = env.list_directory(".", None).await.unwrap(); - assert!(entries.iter().any(|e| e.name == "test.txt")); - - // grep - let grep_results = env - .grep("line2", "test.txt", &GrepOptions::default()) - .await - .unwrap(); - assert_eq!(grep_results.len(), 1); - assert!(grep_results[0].contains("line2")); - - // glob - let glob_results = env.glob("*.txt", None).await.unwrap(); - assert!(glob_results.iter().any(|p| p.contains("test.txt"))); - - // delete_file - env.delete_file("test.txt").await.unwrap(); - assert!(!env.file_exists("test.txt").await.unwrap()); - - // Cleanup - env.cleanup().await.unwrap(); - std::fs::remove_dir_all(&host_dir).ok(); - } - - #[tokio::test] - #[ignore] - async fn timeout_handling() { - let _docker = require_docker(); - let host_dir = - std::env::temp_dir().join(format!("docker_timeout_test_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&host_dir).unwrap(); - - let config = test_config(host_dir.to_str().unwrap()); - let env = DockerSandbox::new(config).unwrap(); - env.initialize().await.unwrap(); - - let result = env - .exec_command("sleep 60", 1000, None, None, None) - .await - .unwrap(); - assert!(result.timed_out); - assert_eq!(result.exit_code, -1); - - env.cleanup().await.unwrap(); - std::fs::remove_dir_all(&host_dir).ok(); - } - - #[tokio::test] - #[ignore] - async fn special_characters_in_write() { - let _docker = require_docker(); - let host_dir = - std::env::temp_dir().join(format!("docker_special_test_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&host_dir).unwrap(); - - let config = test_config(host_dir.to_str().unwrap()); - let env = DockerSandbox::new(config).unwrap(); - env.initialize().await.unwrap(); - - let content = "hello \"world\"\nit's a `test`\nprice: $100\nbackslash: \\\nnewline above"; - env.write_file("special.txt", content).await.unwrap(); - - // Read raw content back via cat to verify exact match - let result = env - .exec_command("cat /workspace/special.txt", 5000, None, None, None) - .await - .unwrap(); - assert_eq!(result.stdout, content); - - env.cleanup().await.unwrap(); - std::fs::remove_dir_all(&host_dir).ok(); - } - - #[tokio::test] - #[ignore] - async fn path_resolution() { - let _docker = require_docker(); - let host_dir = - std::env::temp_dir().join(format!("docker_path_test_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&host_dir).unwrap(); - - let config = test_config(host_dir.to_str().unwrap()); - let env = DockerSandbox::new(config).unwrap(); - env.initialize().await.unwrap(); - - // Relative path resolves to container_mount_point - env.write_file("relative.txt", "relative").await.unwrap(); - assert!(env.file_exists("relative.txt").await.unwrap()); - assert!(env.file_exists("/workspace/relative.txt").await.unwrap()); - - // Absolute path used as-is - env.write_file("/tmp/absolute.txt", "absolute") - .await - .unwrap(); - assert!(env.file_exists("/tmp/absolute.txt").await.unwrap()); - - env.cleanup().await.unwrap(); - std::fs::remove_dir_all(&host_dir).ok(); - } - - #[tokio::test] - #[ignore] - async fn cleanup_idempotent() { - let _docker = require_docker(); - let host_dir = - std::env::temp_dir().join(format!("docker_cleanup_test_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&host_dir).unwrap(); - - let config = test_config(host_dir.to_str().unwrap()); - let env = DockerSandbox::new(config).unwrap(); - env.initialize().await.unwrap(); - - // First cleanup - env.cleanup().await.unwrap(); - // Second cleanup should not error - env.cleanup().await.unwrap(); - - std::fs::remove_dir_all(&host_dir).ok(); - } -} +// Re-export from fabro-sandbox +pub use fabro_sandbox::docker::{DockerSandbox, DockerSandboxConfig}; diff --git a/lib/crates/fabro-agent/src/local_sandbox.rs b/lib/crates/fabro-agent/src/local_sandbox.rs index 9798ea302..7303b5a9a 100644 --- a/lib/crates/fabro-agent/src/local_sandbox.rs +++ b/lib/crates/fabro-agent/src/local_sandbox.rs @@ -1,889 +1,2 @@ -use crate::sandbox::{ - format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, - SandboxEventCallback, -}; -use async_trait::async_trait; -use std::path::{Path, PathBuf}; -use std::time::Instant; -use tokio::io::AsyncReadExt; -use tokio::process::Command; -use tokio_util::sync::CancellationToken; - -pub struct LocalSandbox { - working_directory: PathBuf, - event_callback: Option, - rg_available: std::sync::OnceLock, -} - -impl LocalSandbox { - #[must_use] - pub fn new(working_directory: PathBuf) -> Self { - Self { - working_directory, - event_callback: None, - rg_available: std::sync::OnceLock::new(), - } - } - - 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); - } - } - - const ENV_SAFELIST: &'static [&'static str] = &[ - "PATH", - "HOME", - "USER", - "SHELL", - "LANG", - "TERM", - "TMPDIR", - "GOPATH", - "CARGO_HOME", - "NVM_DIR", - ]; - - fn should_filter_env_var(key: &str) -> bool { - if Self::ENV_SAFELIST.contains(&key) { - return false; - } - let lower = key.to_lowercase(); - lower.ends_with("_api_key") - || lower.ends_with("_secret") - || lower.ends_with("_token") - || lower.ends_with("_password") - || lower.ends_with("_credential") - } - - fn resolve_path(&self, path: &str) -> PathBuf { - let p = Path::new(path); - if p.is_absolute() { - p.to_path_buf() - } else { - self.working_directory.join(p) - } - } -} - -#[async_trait] -impl Sandbox for LocalSandbox { - async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> Result { - let full_path = self.resolve_path(path); - let content = tokio::fs::read_to_string(&full_path) - .await - .map_err(|e| format!("Failed to read {}: {e}", full_path.display()))?; - - Ok(format_lines_numbered(&content, offset, limit)) - } - - async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { - let full_path = self.resolve_path(path); - if let Some(parent) = full_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| format!("Failed to create parent dirs: {e}"))?; - } - tokio::fs::write(&full_path, content) - .await - .map_err(|e| format!("Failed to write {}: {e}", full_path.display())) - } - - async fn delete_file(&self, path: &str) -> Result<(), String> { - let full_path = self.resolve_path(path); - tokio::fs::remove_file(&full_path) - .await - .map_err(|e| format!("Failed to delete {}: {e}", full_path.display())) - } - - async fn file_exists(&self, path: &str) -> Result { - let full_path = self.resolve_path(path); - Ok(full_path.exists()) - } - - async fn list_directory( - &self, - path: &str, - depth: Option, - ) -> Result, String> { - let full_path = self.resolve_path(path); - let max_depth = depth.unwrap_or(1); - - fn list_recursive( - base: &std::path::Path, - prefix: &str, - current_depth: usize, - max_depth: usize, - entries: &mut Vec, - ) -> Result<(), String> { - let mut dir_entries: Vec = std::fs::read_dir(base) - .map_err(|e| format!("Failed to read directory {}: {e}", base.display()))? - .filter_map(std::result::Result::ok) - .collect(); - dir_entries.sort_by_key(std::fs::DirEntry::file_name); - - for entry in dir_entries { - let metadata = entry - .metadata() - .map_err(|e| format!("Failed to read metadata: {e}"))?; - let name = if prefix.is_empty() { - entry.file_name().to_string_lossy().into_owned() - } else { - format!("{prefix}/{}", entry.file_name().to_string_lossy()) - }; - let is_dir = metadata.is_dir(); - entries.push(DirEntry { - name: name.clone(), - is_dir, - size: if metadata.is_file() { - Some(metadata.len()) - } else { - None - }, - }); - if is_dir && current_depth + 1 < max_depth { - list_recursive(&entry.path(), &name, current_depth + 1, max_depth, entries)?; - } - } - Ok(()) - } - - let mut entries = Vec::new(); - list_recursive(&full_path, "", 0, max_depth, &mut entries)?; - Ok(entries) - } - - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - cancel_token: Option, - ) -> Result { - let start = Instant::now(); - - let mut filtered_env: Vec<(String, String)> = std::env::vars() - .filter(|(key, _)| !Self::should_filter_env_var(key)) - .collect(); - - if let Some(extra) = env_vars { - for (k, v) in extra { - filtered_env.push((k.clone(), v.clone())); - } - } - - let effective_dir = - working_dir.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from); - - let mut cmd = Command::new("/bin/bash"); - cmd.arg("-c") - .arg(command) - .current_dir(&effective_dir) - .env_clear() - .envs(filtered_env) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - #[cfg(unix)] - unsafe { - cmd.pre_exec(|| { - libc::setpgid(0, 0); - Ok(()) - }); - } - - let mut child = cmd - .spawn() - .map_err(|e| format!("Failed to spawn command: {e}"))?; - - let timeout_duration = std::time::Duration::from_millis(timeout_ms); - let token = cancel_token.unwrap_or_default(); - - // Take stdout/stderr handles before entering the select! so we can - // drain them concurrently. Without this, the child can deadlock: if - // it writes more than the OS pipe buffer (~64 KB) the write() syscall - // blocks until the parent drains the pipe, but the parent is blocked - // on child.wait(). - let mut stdout_pipe = child.stdout.take(); - let mut stderr_pipe = child.stderr.take(); - let stdout_task = tokio::spawn(async move { - let mut buf = String::new(); - if let Some(ref mut r) = stdout_pipe { - let _ = r.read_to_string(&mut buf).await; - } - buf - }); - let stderr_task = tokio::spawn(async move { - let mut buf = String::new(); - if let Some(ref mut r) = stderr_pipe { - let _ = r.read_to_string(&mut buf).await; - } - buf - }); - - let (timed_out, exit_code) = tokio::select! { - status_result = child.wait() => { - let status = status_result.map_err(|e| format!("Failed to wait for process: {e}"))?; - (false, status.code().unwrap_or(-1)) - } - () = tokio::time::sleep(timeout_duration) => { - sigterm_then_kill(&mut child).await; - (true, -1) - } - () = token.cancelled() => { - sigterm_then_kill(&mut child).await; - (true, -1) - } - }; - - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - - let stdout_str = stdout_task.await.unwrap_or_default(); - let stderr_str = stderr_task.await.unwrap_or_default(); - - Ok(ExecResult { - stdout: stdout_str, - stderr: stderr_str, - exit_code, - timed_out, - duration_ms, - }) - } - - async fn grep( - &self, - pattern: &str, - path: &str, - options: &GrepOptions, - ) -> Result, String> { - let full_path = self.resolve_path(path); - - // Try rg (ripgrep) first, fall back to grep - let use_rg = *self.rg_available.get_or_init(|| { - std::process::Command::new("rg") - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - }); - - let output = if use_rg { - let mut args = vec!["-n".to_string()]; - if options.case_insensitive { - args.push("-i".into()); - } - if let Some(ref glob_filter) = options.glob_filter { - args.push("--glob".into()); - args.push(glob_filter.clone()); - } - if let Some(max) = options.max_results { - args.push("-m".into()); - args.push(max.to_string()); - } - args.push(pattern.into()); - args.push(full_path.to_string_lossy().into_owned()); - - std::process::Command::new("rg") - .args(&args) - .output() - .map_err(|e| format!("Failed to run rg: {e}"))? - } else { - let mut args = vec!["-rn".to_string()]; - if options.case_insensitive { - args.push("-i".into()); - } - if let Some(ref glob_filter) = options.glob_filter { - args.push("--include".into()); - args.push(glob_filter.clone()); - } - if let Some(max) = options.max_results { - args.push("-m".into()); - args.push(max.to_string()); - } - args.push(pattern.into()); - args.push(full_path.to_string_lossy().into_owned()); - - std::process::Command::new("grep") - .args(&args) - .output() - .map_err(|e| format!("Failed to run grep: {e}"))? - }; - - let stdout = String::from_utf8_lossy(&output.stdout); - let results: Vec = stdout - .lines() - .map(String::from) - .filter(|l| !l.is_empty()) - .collect(); - Ok(results) - } - - async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { - let base_dir = - path.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from); - - let full_pattern = if Path::new(pattern).is_absolute() { - pattern.to_string() - } else { - format!("{}/{pattern}", base_dir.display()) - }; - - let mut results: Vec = glob::glob(&full_pattern) - .map_err(|e| format!("Invalid glob pattern: {e}"))? - .filter_map(Result::ok) - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - - // Sort by mtime (newest first) - results.sort_by(|a, b| { - let mtime_a = std::fs::metadata(a) - .and_then(|m| m.modified()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - let mtime_b = std::fs::metadata(b) - .and_then(|m| m.modified()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - mtime_b.cmp(&mtime_a) - }); - - Ok(results) - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &Path, - ) -> Result<(), String> { - let full_path = self.resolve_path(remote_path); - 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::copy(&full_path, local_path).await.map_err(|e| { - format!( - "Failed to copy {} to {}: {e}", - full_path.display(), - local_path.display() - ) - })?; - Ok(()) - } - - async fn upload_file_from_local( - &self, - local_path: &Path, - remote_path: &str, - ) -> Result<(), String> { - let full_path = self.resolve_path(remote_path); - if let Some(parent) = full_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| format!("Failed to create parent dirs: {e}"))?; - } - tokio::fs::copy(local_path, &full_path).await.map_err(|e| { - format!( - "Failed to copy {} to {}: {e}", - local_path.display(), - full_path.display() - ) - })?; - Ok(()) - } - - async fn initialize(&self) -> Result<(), String> { - self.emit(SandboxEvent::Initializing { - provider: "local".into(), - }); - let start = Instant::now(); - let result = tokio::fs::create_dir_all(&self.working_directory) - .await - .map_err(|e| format!("Failed to create working directory: {e}")); - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - match &result { - Ok(()) => self.emit(SandboxEvent::Ready { - provider: "local".into(), - duration_ms, - name: None, - cpu: None, - memory: None, - url: None, - }), - Err(e) => self.emit(SandboxEvent::InitializeFailed { - provider: "local".into(), - error: e.clone(), - duration_ms, - }), - } - result - } - - async fn cleanup(&self) -> Result<(), String> { - self.emit(SandboxEvent::CleanupStarted { - provider: "local".into(), - }); - let start = Instant::now(); - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::CleanupCompleted { - provider: "local".into(), - duration_ms, - }); - Ok(()) - } - - fn working_directory(&self) -> &str { - self.working_directory.to_str().unwrap_or(".") - } - - fn platform(&self) -> &str { - if cfg!(target_os = "macos") { - "darwin" - } else if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "windows") { - "windows" - } else { - "unknown" - } - } - - fn os_version(&self) -> String { - #[cfg(unix)] - { - let output = std::process::Command::new("uname").arg("-r").output(); - match output { - Ok(out) => { - let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); - format!("{} {version}", self.platform()) - } - Err(_) => self.platform().to_string(), - } - } - #[cfg(not(unix))] - { - self.platform().to_string() - } - } -} - -/// Send SIGTERM to the process group, wait 2s for graceful shutdown, then SIGKILL. -async fn sigterm_then_kill(child: &mut tokio::process::Child) { - #[cfg(unix)] - if let Some(pid) = child.id() { - unsafe { - libc::kill(-(pid as i32), libc::SIGTERM); - } - if tokio::time::timeout(std::time::Duration::from_secs(2), child.wait()) - .await - .is_err() - { - let _ = child.kill().await; - let _ = child.wait().await; - } - } else { - let _ = child.kill().await; - let _ = child.wait().await; - } - #[cfg(not(unix))] - { - let _ = child.kill().await; - let _ = child.wait().await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - fn temp_dir() -> PathBuf { - let dir = std::env::temp_dir().join(format!("local_env_test_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[tokio::test] - async fn read_file_with_line_numbers() { - let dir = temp_dir(); - std::fs::write(dir.join("test.txt"), "hello\nworld\nfoo").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let result = env.read_file("test.txt", None, None).await.unwrap(); - - assert_eq!(result, "1 | hello\n2 | world\n3 | foo\n"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn read_file_line_number_padding() { - let dir = temp_dir(); - let content: String = (1..=12).map(|i| format!("line {i}\n")).collect(); - std::fs::write(dir.join("padded.txt"), content.trim_end()).unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let result = env.read_file("padded.txt", None, None).await.unwrap(); - - assert!(result.starts_with(" 1 | line 1\n")); - assert!(result.contains("12 | line 12\n")); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn read_file_not_found() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env.read_file("nonexistent.txt", None, None).await; - assert!(result.is_err()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn write_file_creates_parent_dirs() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - env.write_file("sub/dir/test.txt", "content").await.unwrap(); - - let written = std::fs::read_to_string(dir.join("sub/dir/test.txt")).unwrap(); - assert_eq!(written, "content"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn file_exists_true() { - let dir = temp_dir(); - std::fs::write(dir.join("exists.txt"), "data").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - assert!(env.file_exists("exists.txt").await.unwrap()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn file_exists_false() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - assert!(!env.file_exists("nope.txt").await.unwrap()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn list_directory_sorted() { - let dir = temp_dir(); - std::fs::write(dir.join("b.txt"), "b").unwrap(); - std::fs::write(dir.join("a.txt"), "a").unwrap(); - std::fs::create_dir(dir.join("c_dir")).unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let entries = env.list_directory(".", None).await.unwrap(); - - assert_eq!(entries.len(), 3); - assert_eq!(entries[0].name, "a.txt"); - assert!(!entries[0].is_dir); - assert!(entries[0].size.is_some()); - assert_eq!(entries[1].name, "b.txt"); - assert_eq!(entries[2].name, "c_dir"); - assert!(entries[2].is_dir); - assert!(entries[2].size.is_none()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_echo() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env - .exec_command("echo hello", 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.stdout.trim(), "hello"); - assert_eq!(result.exit_code, 0); - assert!(!result.timed_out); - assert!(result.duration_ms < 5000); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_exit_code() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env - .exec_command("exit 42", 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.exit_code, 42); - assert!(!result.timed_out); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_timeout() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env - .exec_command("sleep 10", 200, None, None, None) - .await - .unwrap(); - - assert!(result.timed_out); - assert_eq!(result.exit_code, -1); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_stderr() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env - .exec_command("echo err >&2", 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.stderr.trim(), "err"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn env_var_filtering() { - assert!(LocalSandbox::should_filter_env_var("OPENAI_API_KEY")); - assert!(LocalSandbox::should_filter_env_var("ANTHROPIC_API_KEY")); - assert!(LocalSandbox::should_filter_env_var("DB_PASSWORD")); - assert!(LocalSandbox::should_filter_env_var("AWS_SECRET")); - assert!(LocalSandbox::should_filter_env_var("AUTH_TOKEN")); - assert!(LocalSandbox::should_filter_env_var("MY_CREDENTIAL")); - // Case insensitive - assert!(LocalSandbox::should_filter_env_var("my_api_key")); - assert!(LocalSandbox::should_filter_env_var("Some_Secret")); - // Should not filter - assert!(!LocalSandbox::should_filter_env_var("PATH")); - assert!(!LocalSandbox::should_filter_env_var("HOME")); - assert!(!LocalSandbox::should_filter_env_var("EDITOR")); - assert!(!LocalSandbox::should_filter_env_var("SECRET_PATH")); - } - - #[test] - fn platform_is_known() { - let env = LocalSandbox::new(PathBuf::from("/tmp")); - let platform = env.platform(); - assert!( - platform == "darwin" || platform == "linux" || platform == "windows", - "Unknown platform: {platform}" - ); - } - - #[test] - fn os_version_contains_platform() { - let env = LocalSandbox::new(PathBuf::from("/tmp")); - let version = env.os_version(); - assert!( - version.contains(env.platform()), - "OS version should contain platform: {version}" - ); - } - - #[test] - fn working_directory_accessor() { - let env = LocalSandbox::new(PathBuf::from("/tmp/test_dir")); - assert_eq!(env.working_directory(), "/tmp/test_dir"); - } - - #[tokio::test] - async fn initialize_creates_directory() { - let dir = std::env::temp_dir().join(format!("init_test_{}", uuid::Uuid::new_v4())); - let env = LocalSandbox::new(dir.clone()); - env.initialize().await.unwrap(); - assert!(dir.exists()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn initialize_emits_events() { - use crate::sandbox::SandboxEvent; - use std::sync::{Arc, Mutex}; - - let dir = std::env::temp_dir().join(format!("init_event_test_{}", uuid::Uuid::new_v4())); - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let events_clone = Arc::clone(&events); - - let mut env = LocalSandbox::new(dir.clone()); - env.set_event_callback(Arc::new(move |e| { - events_clone.lock().unwrap().push(e); - })); - - env.initialize().await.unwrap(); - - let captured = events.lock().unwrap(); - assert_eq!(captured.len(), 2); - assert!( - matches!(&captured[0], SandboxEvent::Initializing { provider } if provider == "local") - ); - assert!( - matches!(&captured[1], SandboxEvent::Ready { provider, .. } if provider == "local") - ); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn cleanup_emits_events() { - use crate::sandbox::SandboxEvent; - use std::sync::{Arc, Mutex}; - - let dir = temp_dir(); - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let events_clone = Arc::clone(&events); - - let mut env = LocalSandbox::new(dir.clone()); - env.set_event_callback(Arc::new(move |e| { - events_clone.lock().unwrap().push(e); - })); - - env.cleanup().await.unwrap(); - - let captured = events.lock().unwrap(); - assert_eq!(captured.len(), 2); - assert!( - matches!(&captured[0], SandboxEvent::CleanupStarted { provider } if provider == "local") - ); - assert!( - matches!(&captured[1], SandboxEvent::CleanupCompleted { provider, .. } if provider == "local") - ); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn grep_finds_matches() { - let dir = temp_dir(); - std::fs::write( - dir.join("test.rs"), - "fn main() {\n println!(\"hello\");\n}\n", - ) - .unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env - .grep("println", "test.rs", &GrepOptions::default()) - .await - .unwrap(); - - assert_eq!(results.len(), 1); - assert!(results[0].contains("println")); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn grep_case_insensitive() { - let dir = temp_dir(); - std::fs::write(dir.join("test.txt"), "Hello\nhello\nHELLO\n").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env - .grep( - "hello", - "test.txt", - &GrepOptions { - case_insensitive: true, - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(results.len(), 3); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn grep_max_results() { - let dir = temp_dir(); - std::fs::write(dir.join("test.txt"), "match1\nmatch2\nmatch3\nmatch4\n").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env - .grep( - "match", - "test.txt", - &GrepOptions { - max_results: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(results.len(), 2); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn glob_finds_files() { - let dir = temp_dir(); - std::fs::write(dir.join("a.rs"), "").unwrap(); - std::fs::write(dir.join("b.rs"), "").unwrap(); - std::fs::write(dir.join("c.txt"), "").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env.glob("*.rs", None).await.unwrap(); - - assert_eq!(results.len(), 2); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn local_sandbox_download_file_to_local() { - let dir = temp_dir(); - std::fs::write(dir.join("source.txt"), "hello download").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let dest = dir.join("output/downloaded.txt"); - env.download_file_to_local("source.txt", &dest) - .await - .unwrap(); - - assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello download"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn local_sandbox_download_file_to_local_creates_parent_dirs() { - let dir = temp_dir(); - std::fs::write(dir.join("data.bin"), "binary-ish").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let dest = dir.join("deep/nested/dir/data.bin"); - env.download_file_to_local("data.bin", &dest).await.unwrap(); - - assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary-ish"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn local_sandbox_download_file_to_local_binary() { - let dir = temp_dir(); - let binary_data: Vec = (0u8..=255).collect(); - std::fs::write(dir.join("binary.bin"), &binary_data).unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let dest = dir.join("out/binary.bin"); - env.download_file_to_local("binary.bin", &dest) - .await - .unwrap(); - - assert_eq!(std::fs::read(&dest).unwrap(), binary_data); - std::fs::remove_dir_all(&dir).unwrap(); - } -} +// Re-export from fabro-sandbox +pub use fabro_sandbox::local::LocalSandbox; diff --git a/lib/crates/fabro-agent/src/read_before_write_sandbox.rs b/lib/crates/fabro-agent/src/read_before_write_sandbox.rs index 734c4d12e..11fd2d523 100644 --- a/lib/crates/fabro-agent/src/read_before_write_sandbox.rs +++ b/lib/crates/fabro-agent/src/read_before_write_sandbox.rs @@ -1,272 +1 @@ -use crate::sandbox::*; -use std::collections::HashSet; -use std::path::{Component, PathBuf}; -use std::sync::{Arc, Mutex}; -use tracing::{debug, warn}; - -/// Decorator that prevents writing to files the agent hasn't read first. -/// -/// Tracks which file paths the agent has seen (via `mark_agent_read`, called by -/// tool executors after agent-visible reads) and returns an error when `write_file` -/// or `delete_file` targets an existing file that hasn't been read. -/// Writing to new (non-existent) files is always allowed. -pub struct ReadBeforeWriteSandbox { - inner: Arc, - read_set: Mutex>, -} - -impl ReadBeforeWriteSandbox { - pub fn new(inner: Arc) -> Self { - Self { - inner, - read_set: Mutex::new(HashSet::new()), - } - } - - fn normalize_path(&self, path: &str) -> String { - let full = if path.starts_with('/') { - PathBuf::from(path) - } else { - PathBuf::from(self.inner.working_directory()).join(path) - }; - - let mut parts: Vec = Vec::new(); - for component in full.components() { - match component { - Component::Normal(s) => parts.push(s.to_string_lossy().into_owned()), - Component::ParentDir => { - parts.pop(); - } - Component::RootDir | Component::CurDir | Component::Prefix(_) => {} - } - } - - format!("/{}", parts.join("/")) - } - - fn mark_read(&self, path: &str) { - let normalized = self.normalize_path(path); - self.read_set - .lock() - .expect("read_set lock poisoned") - .insert(normalized); - } - - fn has_read(&self, path: &str) -> bool { - let normalized = self.normalize_path(path); - self.read_set - .lock() - .expect("read_set lock poisoned") - .contains(&normalized) - } - - async fn guard_write(&self, path: &str) -> Result<(), String> { - let normalized = self.normalize_path(path); - if normalized.starts_with("/tmp/") { - return Ok(()); - } - let exists = self.inner.file_exists(path).await?; - if exists && !self.has_read(path) { - warn!(path = %path, "Write blocked: file not read by agent"); - Err(format!( - "Cannot write to '{path}': file exists but has not been read. \ - Use read_file to read the file before writing to it." - )) - } else { - Ok(()) - } - } -} - -crate::delegate_sandbox! { - ReadBeforeWriteSandbox => inner { - async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { - self.guard_write(path).await?; - self.inner.write_file(path, content).await - } - - async fn delete_file(&self, path: &str) -> Result<(), String> { - self.guard_write(path).await?; - self.inner.delete_file(path).await - } - - fn mark_agent_read(&self, path: &str) { - debug!(path = %path, "File marked as agent-read"); - self.mark_read(path); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::MockSandbox; - use std::collections::HashMap; - - fn mock_with_files(files: HashMap) -> MockSandbox { - MockSandbox { - files, - working_dir: "/work", - ..Default::default() - } - } - - // Cycle 1: write to existing unread file → error - #[tokio::test] - async fn write_to_existing_unread_file_returns_error() { - let mock = mock_with_files(HashMap::from([("a.ts".into(), "content".into())])); - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - let result = env.write_file("a.ts", "new content").await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.contains("a.ts")); - assert!(err.contains("read")); - } - - // Cycle 2: write to non-existent file → success - #[tokio::test] - async fn write_to_nonexistent_file_succeeds() { - let mock = mock_with_files(HashMap::new()); - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - let result = env.write_file("new.ts", "content").await; - - assert!(result.is_ok()); - } - - // Cycle 3: mark_agent_read then write → success - #[tokio::test] - async fn read_then_write_succeeds() { - let mock = mock_with_files(HashMap::from([("a.ts".into(), "content".into())])); - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - env.mark_agent_read("a.ts"); - let result = env.write_file("a.ts", "new content").await; - - assert!(result.is_ok()); - } - - // Cycle 4: read_file alone does NOT satisfy guard - #[tokio::test] - async fn read_file_alone_does_not_satisfy_guard() { - let mock = mock_with_files(HashMap::from([("a.ts".into(), "content".into())])); - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - env.read_file("a.ts", None, None).await.unwrap(); - let result = env.write_file("a.ts", "new content").await; - - assert!(result.is_err()); - } - - // Cycle 5: grep alone does NOT populate read set - #[tokio::test] - async fn grep_does_not_populate_read_set() { - let mock = MockSandbox { - files: HashMap::from([("b.ts".into(), "content".into())]), - grep_results: vec!["b.ts:1:content".into()], - working_dir: "/work", - ..Default::default() - }; - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - env.grep("pattern", ".", &GrepOptions::default()) - .await - .unwrap(); - let result = env.write_file("b.ts", "new").await; - - assert!(result.is_err()); - } - - // Cycle 6: mark_agent_read from grep results then write → success - #[tokio::test] - async fn mark_agent_read_from_grep_then_write_succeeds() { - let mock = MockSandbox { - files: HashMap::from([("b.ts".into(), "content".into())]), - grep_results: vec!["b.ts:1:content".into()], - working_dir: "/work", - ..Default::default() - }; - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - env.mark_agent_read("b.ts"); - let result = env.write_file("b.ts", "new").await; - - assert!(result.is_ok()); - } - - // Cycle 7: glob does NOT populate read set - #[tokio::test] - async fn glob_does_not_populate_read_set() { - let mock = MockSandbox { - files: HashMap::from([("c.ts".into(), "content".into())]), - glob_results: vec!["c.ts".into()], - working_dir: "/work", - ..Default::default() - }; - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - env.glob("*.ts", None).await.unwrap(); - let result = env.write_file("c.ts", "new").await; - - assert!(result.is_err()); - } - - // Cycle 8: path normalization — relative vs absolute via mark_agent_read - #[tokio::test] - async fn path_normalization_relative_and_absolute() { - let mock = MockSandbox { - files: HashMap::from([ - ("a.ts".into(), "content".into()), - ("/work/a.ts".into(), "content".into()), - ]), - working_dir: "/work", - ..Default::default() - }; - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - env.mark_agent_read("a.ts"); - let result = env.write_file("/work/a.ts", "new content").await; - - assert!(result.is_ok()); - } - - // Cycle 9: delete unread file → error - #[tokio::test] - async fn delete_unread_file_returns_error() { - let mock = mock_with_files(HashMap::from([("d.ts".into(), "content".into())])); - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - let result = env.delete_file("d.ts").await; - - assert!(result.is_err()); - } - - // Cycle 10: error message is actionable - #[tokio::test] - async fn error_message_is_actionable() { - let mock = mock_with_files(HashMap::from([("main.rs".into(), "fn main() {}".into())])); - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - let err = env.write_file("main.rs", "new").await.unwrap_err(); - - assert!(err.contains("main.rs")); - assert!(err.contains("read_file")); - } - - // Cycle 11: write to /tmp bypasses guard - #[tokio::test] - async fn write_to_tmp_bypasses_guard() { - let mock = MockSandbox { - files: HashMap::from([("/tmp/fabro-commit-msg".into(), "old".into())]), - working_dir: "/work", - ..Default::default() - }; - let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); - - let result = env.write_file("/tmp/fabro-commit-msg", "new").await; - - assert!(result.is_ok()); - } -} +pub use fabro_sandbox::read_guard::ReadBeforeWriteSandbox; diff --git a/lib/crates/fabro-agent/src/sandbox.rs b/lib/crates/fabro-agent/src/sandbox.rs index 92ac6d50c..887a5682e 100644 --- a/lib/crates/fabro-agent/src/sandbox.rs +++ b/lib/crates/fabro-agent/src/sandbox.rs @@ -1,595 +1,9 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fmt::Write; -use std::path::Path; -use std::sync::Arc; -use tokio_util::sync::CancellationToken; +// Re-export all sandbox types from fabro-sandbox. +pub use fabro_sandbox::{ + format_lines_numbered, shell_quote, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, +}; -/// Generates an `#[async_trait] impl Sandbox` block for a decorator type -/// that wraps an `Arc`. The caller provides custom method -/// implementations; all remaining trait methods delegate to the inner field. -/// -/// # Usage -/// -/// ```ignore -/// delegate_sandbox! { -/// MyDecorator => inner { -/// // Only provide methods with custom logic — the rest delegate automatically. -/// async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result { -/// // custom logic... -/// } -/// } -/// } -/// ``` -#[macro_export] -macro_rules! delegate_sandbox { - ( - $type:ty => $field:ident { - $($custom:item)* - } - ) => { - #[async_trait::async_trait] - impl $crate::sandbox::Sandbox for $type { - $($custom)* - - async fn file_exists(&self, path: &str) -> Result { - self.$field.file_exists(path).await - } - - async fn list_directory( - &self, - path: &str, - depth: Option, - ) -> Result, String> { - self.$field.list_directory(path, depth).await - } - - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - cancel_token: Option, - ) -> Result<$crate::sandbox::ExecResult, String> { - self.$field - .exec_command(command, timeout_ms, working_dir, env_vars, cancel_token) - .await - } - - async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { - self.$field.glob(pattern, path).await - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &std::path::Path, - ) -> Result<(), String> { - self.$field.download_file_to_local(remote_path, local_path).await - } - - async fn upload_file_from_local( - &self, - local_path: &std::path::Path, - remote_path: &str, - ) -> Result<(), String> { - self.$field.upload_file_from_local(local_path, remote_path).await - } - - async fn initialize(&self) -> Result<(), String> { - self.$field.initialize().await - } - - async fn cleanup(&self) -> Result<(), String> { - self.$field.cleanup().await - } - - fn working_directory(&self) -> &str { - self.$field.working_directory() - } - - fn platform(&self) -> &str { - self.$field.platform() - } - - fn os_version(&self) -> String { - self.$field.os_version() - } - - fn sandbox_info(&self) -> String { - self.$field.sandbox_info() - } - - async fn refresh_push_credentials(&self) -> Result<(), String> { - self.$field.refresh_push_credentials().await - } - - async fn set_autostop_interval(&self, minutes: i32) -> Result<(), String> { - self.$field.set_autostop_interval(minutes).await - } - - fn is_remote(&self) -> bool { - self.$field.is_remote() - } - - async fn ssh_access_command(&self) -> Result, String> { - self.$field.ssh_access_command().await - } - - fn origin_url(&self) -> Option<&str> { - self.$field.origin_url() - } - - async fn get_preview_url(&self, port: u16) -> Result)>, String> { - self.$field.get_preview_url(port).await - } - - async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> Result { - self.$field.read_file(path, offset, limit).await - } - - async fn grep( - &self, - pattern: &str, - path: &str, - options: &$crate::sandbox::GrepOptions, - ) -> Result, String> { - self.$field.grep(pattern, path, options).await - } - } - }; -} - -/// Events emitted during sandbox lifecycle operations. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum SandboxEvent { - // -- Common lifecycle -- - Initializing { - provider: String, - }, - Ready { - provider: String, - duration_ms: u64, - name: Option, - cpu: Option, - memory: Option, - url: Option, - }, - InitializeFailed { - provider: String, - error: String, - duration_ms: u64, - }, - CleanupStarted { - provider: String, - }, - CleanupCompleted { - provider: String, - duration_ms: u64, - }, - CleanupFailed { - provider: String, - error: String, - }, - - // -- Docker -- - SnapshotPulling { - name: String, - }, - SnapshotPulled { - name: String, - duration_ms: u64, - }, - - // -- Daytona snapshots -- - SnapshotEnsuring { - name: String, - }, - SnapshotCreating { - name: String, - }, - SnapshotReady { - name: String, - duration_ms: u64, - }, - SnapshotFailed { - name: String, - error: String, - }, - - // -- Daytona git -- - GitCloneStarted { - url: String, - branch: Option, - }, - GitCloneCompleted { - url: String, - duration_ms: u64, - }, - GitCloneFailed { - url: String, - error: String, - }, -} - -impl SandboxEvent { - pub fn trace(&self) { - use tracing::{debug, error, info, warn}; - match self { - Self::Initializing { provider } => { - debug!(provider, "Sandbox initializing"); - } - Self::Ready { - provider, - duration_ms, - .. - } => { - info!(provider, duration_ms, "Sandbox ready"); - } - Self::InitializeFailed { - provider, - error, - duration_ms, - } => { - error!(provider, error, duration_ms, "Sandbox init failed"); - } - Self::CleanupStarted { provider } => { - debug!(provider, "Sandbox cleanup started"); - } - Self::CleanupCompleted { - provider, - duration_ms, - } => { - debug!(provider, duration_ms, "Sandbox cleanup completed"); - } - Self::CleanupFailed { provider, error } => { - warn!(provider, error, "Sandbox cleanup failed"); - } - Self::SnapshotPulling { name } => { - debug!(name, "Snapshot pulling"); - } - Self::SnapshotPulled { name, duration_ms } => { - debug!(name, duration_ms, "Snapshot pulled"); - } - Self::SnapshotEnsuring { name } => { - debug!(name, "Snapshot ensuring"); - } - Self::SnapshotCreating { name } => { - debug!(name, "Snapshot creating"); - } - Self::SnapshotReady { name, duration_ms } => { - info!(name, duration_ms, "Snapshot ready"); - } - Self::SnapshotFailed { name, error } => { - error!(name, error, "Snapshot failed"); - } - Self::GitCloneStarted { url, branch } => { - debug!( - url, - branch = branch.as_deref().unwrap_or(""), - "Git clone started" - ); - } - Self::GitCloneCompleted { url, duration_ms } => { - debug!(url, duration_ms, "Git clone completed"); - } - Self::GitCloneFailed { url, error } => { - error!(url, error, "Git clone failed"); - } - } - } -} - -/// Callback type for sandbox events. -pub type SandboxEventCallback = Arc; - -/// Formats file content with line numbers for display. -/// -/// Applies optional offset (0-based lines to skip) and limit (max lines to return). -/// Line numbers are 1-based and right-aligned. -#[must_use] -pub fn format_lines_numbered(content: &str, offset: Option, limit: Option) -> String { - let all_lines: Vec<&str> = content.lines().collect(); - let skip = offset.unwrap_or(0); - let take = limit.unwrap_or(all_lines.len()); - let selected: Vec<&str> = all_lines.into_iter().skip(skip).take(take).collect(); - let width = (skip + selected.len()).to_string().len().max(1); - let mut result = String::new(); - for (i, line) in selected.iter().enumerate() { - let line_num = skip + i + 1; - let _ = writeln!(result, "{line_num:>width$} | {line}"); - } - result -} - -#[derive(Debug, Clone)] -pub struct ExecResult { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, - pub timed_out: bool, - pub duration_ms: u64, -} - -#[derive(Debug, Clone)] -pub struct DirEntry { - pub name: String, - pub is_dir: bool, - pub size: Option, -} - -#[derive(Debug, Clone, Default)] -pub struct GrepOptions { - pub glob_filter: Option, - pub case_insensitive: bool, - pub max_results: Option, -} - -#[async_trait] -pub trait Sandbox: Send + Sync { - async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> Result; - async fn write_file(&self, path: &str, content: &str) -> Result<(), String>; - async fn delete_file(&self, path: &str) -> Result<(), String>; - async fn file_exists(&self, path: &str) -> Result; - async fn list_directory( - &self, - path: &str, - depth: Option, - ) -> Result, String>; - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - cancel_token: Option, - ) -> Result; - async fn grep( - &self, - pattern: &str, - path: &str, - options: &GrepOptions, - ) -> Result, String>; - async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String>; - /// Copy a file from the sandbox to a local filesystem path. - /// Handles binary files correctly across all sandbox types. - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &Path, - ) -> Result<(), String>; - /// Copy a file from the local filesystem into the sandbox. - /// Handles binary files correctly across all sandbox types. - async fn upload_file_from_local( - &self, - local_path: &Path, - remote_path: &str, - ) -> Result<(), String>; - async fn initialize(&self) -> Result<(), String>; - async fn cleanup(&self) -> Result<(), String>; - fn working_directory(&self) -> &str; - fn platform(&self) -> &str; - fn os_version(&self) -> String; - /// Return a human-readable identifier for the sandbox (e.g. container ID, sandbox name). - /// Used when `--preserve-sandbox` is active to tell the user how to reconnect. - fn sandbox_info(&self) -> String { - String::new() - } - - /// Refresh git push credentials (e.g. rotate an expiring GitHub App token). - /// Default is a no-op; Daytona overrides to update the remote URL with a fresh token. - async fn refresh_push_credentials(&self) -> Result<(), String> { - Ok(()) - } - - /// Set the auto-stop interval in minutes (0 to disable). - /// Default is a no-op; Daytona overrides to call the Daytona API. - async fn set_autostop_interval(&self, _minutes: i32) -> Result<(), String> { - Ok(()) - } - - /// Whether this sandbox runs on a remote machine (e.g. Daytona, exe.dev). - fn is_remote(&self) -> bool { - false - } - - /// Return an SSH command string for connecting to this sandbox, if supported. - async fn ssh_access_command(&self) -> Result, String> { - Ok(None) - } - - /// The display URL of the cloned origin remote, if known. - fn origin_url(&self) -> Option<&str> { - None - } - - /// Get an authenticated preview URL for a port exposed by this sandbox. - /// Returns `Ok(None)` when the sandbox does not support port previews. - /// Used to connect to services (e.g. MCP servers) running inside the sandbox. - async fn get_preview_url( - &self, - _port: u16, - ) -> Result)>, String> { - Ok(None) - } - - /// Record that the agent has explicitly read (seen) the given file path. - /// Called by tool executors after agent-visible reads (e.g. `read_file`, `grep`). - /// Default is a no-op; `ReadBeforeWriteSandbox` overrides to populate its read set. - fn mark_agent_read(&self, _path: &str) {} -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::MockSandbox; - use std::collections::HashMap; - use std::sync::Arc; - - #[tokio::test] - async fn mock_env_read_file() { - let mut files = HashMap::new(); - files.insert("test.rs".into(), "hello".into()); - let env: Arc = Arc::new(MockSandbox { - files, - ..Default::default() - }); - let result = env.read_file("test.rs", None, None).await.unwrap(); - assert_eq!(result, "hello"); - } - - #[tokio::test] - async fn mock_env_exec_command() { - let env: Arc = Arc::new(MockSandbox::default()); - let result = env - .exec_command("echo", 5000, None, None, None) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - assert!(!result.timed_out); - } - - #[tokio::test] - async fn mock_env_list_directory() { - let env: Arc = Arc::new(MockSandbox::default()); - let entries = env.list_directory("/tmp", None).await.unwrap(); - assert_eq!(entries.len(), 0); - } - - #[test] - fn exec_result_fields() { - let result = ExecResult { - stdout: "out".into(), - stderr: "err".into(), - exit_code: 1, - timed_out: true, - duration_ms: 5000, - }; - assert_eq!(result.exit_code, 1); - assert!(result.timed_out); - assert_eq!(result.duration_ms, 5000); - } - - #[test] - fn dir_entry_fields() { - let entry = DirEntry { - name: "src".into(), - is_dir: true, - size: None, - }; - assert_eq!(entry.name, "src"); - assert!(entry.is_dir); - assert!(entry.size.is_none()); - } - - #[test] - fn grep_options_defaults() { - let opts = GrepOptions::default(); - assert!(opts.glob_filter.is_none()); - assert!(!opts.case_insensitive); - assert!(opts.max_results.is_none()); - } - - #[test] - fn mock_env_platform() { - let env = MockSandbox::default(); - assert_eq!(env.platform(), "darwin"); - assert_eq!(env.working_directory(), "/work"); - assert_eq!(env.os_version(), "Darwin 24.0.0"); - } - - #[test] - fn sandbox_event_serialization_round_trip() { - let events = vec![ - SandboxEvent::Initializing { - provider: "local".into(), - }, - SandboxEvent::Ready { - provider: "local".into(), - duration_ms: 50, - name: None, - cpu: None, - memory: None, - url: None, - }, - SandboxEvent::InitializeFailed { - provider: "docker".into(), - error: "no daemon".into(), - duration_ms: 100, - }, - SandboxEvent::CleanupStarted { - provider: "daytona".into(), - }, - SandboxEvent::CleanupCompleted { - provider: "daytona".into(), - duration_ms: 200, - }, - SandboxEvent::CleanupFailed { - provider: "docker".into(), - error: "container gone".into(), - }, - SandboxEvent::SnapshotPulling { - name: "ubuntu:22.04".into(), - }, - SandboxEvent::SnapshotPulled { - name: "ubuntu:22.04".into(), - duration_ms: 5000, - }, - SandboxEvent::SnapshotEnsuring { - name: "my-snap".into(), - }, - SandboxEvent::SnapshotCreating { - name: "my-snap".into(), - }, - SandboxEvent::SnapshotReady { - name: "my-snap".into(), - duration_ms: 30000, - }, - SandboxEvent::SnapshotFailed { - name: "my-snap".into(), - error: "build failed".into(), - }, - SandboxEvent::GitCloneStarted { - url: "https://github.com/org/repo.git".into(), - branch: Some("main".into()), - }, - SandboxEvent::GitCloneCompleted { - url: "https://github.com/org/repo.git".into(), - duration_ms: 8000, - }, - SandboxEvent::GitCloneFailed { - url: "https://github.com/org/repo.git".into(), - error: "auth failed".into(), - }, - ]; - - assert_eq!(events.len(), 15, "should test all 15 variants"); - - for event in &events { - let json = serde_json::to_string(event).unwrap(); - let deserialized: SandboxEvent = serde_json::from_str(&json).unwrap(); - let json2 = serde_json::to_string(&deserialized).unwrap(); - assert_eq!(json, json2); - } - } - - #[test] - fn sandbox_event_callback_type_compiles() { - let cb: SandboxEventCallback = Arc::new(|_event| {}); - cb(SandboxEvent::Initializing { - provider: "test".into(), - }); - } -} +// Re-export the delegate_sandbox! macro at crate root so existing +// `crate::delegate_sandbox!` invocations continue to work. +pub use fabro_sandbox::delegate_sandbox; diff --git a/lib/crates/fabro-agent/src/test_support.rs b/lib/crates/fabro-agent/src/test_support.rs index c3ba4ceb2..705ce2641 100644 --- a/lib/crates/fabro-agent/src/test_support.rs +++ b/lib/crates/fabro-agent/src/test_support.rs @@ -1,3 +1,5 @@ +pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox}; + use crate::config::SessionConfig; use crate::profiles::EnvContext; use crate::provider_profile::{ProfileCapabilities, ProviderProfile}; @@ -13,390 +15,6 @@ use fabro_llm::types::{ContentPart, FinishReason, Message, Request, Response, St use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use tokio_util::sync::CancellationToken; - -// --- MockSandbox --- - -pub struct MockSandbox { - pub files: HashMap, - pub exec_result: ExecResult, - pub grep_results: Vec, - pub glob_results: Vec, - pub working_dir: &'static str, - pub platform_str: &'static str, - pub os_version_str: String, - /// When true, `read_file` applies offset/limit by splitting on lines. - pub apply_read_offset_limit: bool, - /// Captures (path, content) pairs from `write_file` calls. - pub written_files: Mutex>, - /// Captures the `timeout_ms` argument from `exec_command` calls. - pub captured_timeout: Mutex>, - /// Captures the `command` argument from `exec_command` calls. - pub captured_command: Mutex>, - /// Captures the `env_vars` argument from `exec_command` calls. - pub captured_env_vars: Mutex>>, - pub event_callback: Option, -} - -impl MockSandbox { - pub fn linux() -> Self { - Self { - working_dir: "/home/test", - platform_str: "linux", - os_version_str: "Linux 6.1.0".into(), - ..Default::default() - } - } -} - -impl MockSandbox { - fn emit(&self, event: crate::sandbox::SandboxEvent) { - event.trace(); - if let Some(ref cb) = self.event_callback { - cb(event); - } - } -} - -impl Default for MockSandbox { - fn default() -> Self { - Self { - files: HashMap::new(), - exec_result: ExecResult { - stdout: "mock output".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, - duration_ms: 10, - }, - grep_results: vec![], - glob_results: vec![], - working_dir: "/work", - platform_str: "darwin", - os_version_str: "Darwin 24.0.0".into(), - apply_read_offset_limit: false, - written_files: Mutex::new(Vec::new()), - captured_timeout: Mutex::new(None), - captured_command: Mutex::new(None), - captured_env_vars: Mutex::new(None), - event_callback: None, - } - } -} - -#[async_trait] -impl Sandbox for MockSandbox { - async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> Result { - let content = self - .files - .get(path) - .cloned() - .ok_or_else(|| format!("File not found: {path}"))?; - - if self.apply_read_offset_limit { - let lines: Vec<&str> = content.lines().collect(); - let start = offset.unwrap_or(1).saturating_sub(1); - let count = limit.unwrap_or(2000); - let selected: Vec<&str> = lines.into_iter().skip(start).take(count).collect(); - Ok(selected.join("\n")) - } else { - Ok(content) - } - } - - async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { - self.written_files - .lock() - .expect("written_files lock poisoned") - .push((path.to_string(), content.to_string())); - Ok(()) - } - - async fn delete_file(&self, _path: &str) -> Result<(), String> { - Ok(()) - } - - async fn file_exists(&self, path: &str) -> Result { - Ok(self.files.contains_key(path)) - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> Result, String> { - Ok(vec![]) - } - - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - _working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> Result { - *self - .captured_timeout - .lock() - .expect("captured_timeout lock poisoned") = Some(timeout_ms); - *self - .captured_command - .lock() - .expect("captured_command lock poisoned") = Some(command.to_string()); - *self - .captured_env_vars - .lock() - .expect("captured_env_vars lock poisoned") = env_vars.cloned(); - Ok(self.exec_result.clone()) - } - - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &GrepOptions, - ) -> Result, String> { - Ok(self.grep_results.clone()) - } - - async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result, String> { - Ok(self.glob_results.clone()) - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &std::path::Path, - ) -> Result<(), String> { - let content = self - .files - .get(remote_path) - .ok_or_else(|| format!("File not found: {remote_path}"))?; - 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, content.as_bytes()) - .await - .map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?; - Ok(()) - } - - async fn upload_file_from_local( - &self, - local_path: &std::path::Path, - _remote_path: &str, - ) -> Result<(), String> { - if !local_path.exists() { - return Err(format!("File not found: {}", local_path.display())); - } - Ok(()) - } - - async fn initialize(&self) -> Result<(), String> { - self.emit(crate::sandbox::SandboxEvent::Initializing { - provider: "mock".into(), - }); - self.emit(crate::sandbox::SandboxEvent::Ready { - provider: "mock".into(), - duration_ms: 0, - name: None, - cpu: None, - memory: None, - url: None, - }); - Ok(()) - } - - async fn cleanup(&self) -> Result<(), String> { - self.emit(crate::sandbox::SandboxEvent::CleanupStarted { - provider: "mock".into(), - }); - self.emit(crate::sandbox::SandboxEvent::CleanupCompleted { - provider: "mock".into(), - duration_ms: 0, - }); - Ok(()) - } - - fn working_directory(&self) -> &str { - self.working_dir - } - - fn platform(&self) -> &str { - self.platform_str - } - - fn os_version(&self) -> String { - self.os_version_str.clone() - } -} - -// --- MutableMockSandbox --- - -/// A mock sandbox with Mutex-protected files for tests that need -/// write operations to be visible to subsequent reads (e.g., `apply_patch` tests). -pub struct MutableMockSandbox { - pub files: Mutex>, -} - -impl MutableMockSandbox { - pub fn new(files: HashMap) -> Self { - Self { - files: Mutex::new(files), - } - } -} - -#[async_trait] -impl Sandbox for MutableMockSandbox { - async fn read_file( - &self, - path: &str, - _offset: Option, - _limit: Option, - ) -> Result { - self.files - .lock() - .expect("files lock poisoned") - .get(path) - .cloned() - .ok_or_else(|| format!("File not found: {path}")) - } - - async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { - self.files - .lock() - .expect("files lock poisoned") - .insert(path.to_string(), content.to_string()); - Ok(()) - } - - async fn delete_file(&self, path: &str) -> Result<(), String> { - self.files.lock().expect("files lock poisoned").remove(path); - Ok(()) - } - - async fn file_exists(&self, path: &str) -> Result { - Ok(self - .files - .lock() - .expect("files lock poisoned") - .contains_key(path)) - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> Result, String> { - Ok(vec![]) - } - - async fn exec_command( - &self, - _command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> Result { - Ok(ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: 0, - timed_out: false, - duration_ms: 0, - }) - } - - async fn grep( - &self, - pattern: &str, - _path: &str, - _options: &GrepOptions, - ) -> Result, String> { - let files = self.files.lock().expect("files lock poisoned"); - let mut results = Vec::new(); - for (path, content) in files.iter() { - for (i, line) in content.lines().enumerate() { - if line.contains(pattern) { - results.push(format!("{}:{}:{}", path, i + 1, line)); - } - } - } - Ok(results) - } - - async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result, String> { - Ok(vec![]) - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &std::path::Path, - ) -> Result<(), String> { - let content = self - .files - .lock() - .expect("files lock poisoned") - .get(remote_path) - .cloned() - .ok_or_else(|| format!("File not found: {remote_path}"))?; - 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, content.as_bytes()) - .await - .map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?; - Ok(()) - } - - async fn upload_file_from_local( - &self, - local_path: &std::path::Path, - remote_path: &str, - ) -> Result<(), String> { - let content = tokio::fs::read_to_string(local_path) - .await - .map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?; - self.files - .lock() - .expect("files lock poisoned") - .insert(remote_path.to_string(), content); - Ok(()) - } - - async fn initialize(&self) -> Result<(), String> { - Ok(()) - } - - async fn cleanup(&self) -> Result<(), String> { - Ok(()) - } - - fn working_directory(&self) -> &'static str { - "/work" - } - - fn platform(&self) -> &'static str { - "linux" - } - - fn os_version(&self) -> String { - "Linux 6.1.0".into() - } -} // --- TestProfile --- diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml index 9cd77830f..620d73a31 100644 --- a/lib/crates/fabro-api/Cargo.toml +++ b/lib/crates/fabro-api/Cargo.toml @@ -9,12 +9,12 @@ description = "HTTP API server for Fabro pipelines" doctest = false [dependencies] -fabro-config = { path = "../fabro-config" } +fabro-config = { path = "../fabro-config", features = ["exedev"] } fabro-graphviz = { path = "../fabro-graphviz" } fabro-hooks = { path = "../fabro-hooks" } fabro-interview = { path = "../fabro-interview" } fabro-workflows = { path = "../fabro-workflows" } -fabro-daytona = { path = "../fabro-daytona" } +fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } fabro-github = { path = "../fabro-github" } fabro-agent = { path = "../fabro-agent" } fabro-llm = { path = "../fabro-llm" } @@ -62,4 +62,4 @@ http-body-util = "0.1" tempfile = "3" openapiv3 = "2" serde_yaml = "0.9" -fabro-exe = { path = "../fabro-exe" } +fabro-sandbox = { path = "../fabro-sandbox", features = ["exe"] } diff --git a/lib/crates/fabro-api/src/demo/mod.rs b/lib/crates/fabro-api/src/demo/mod.rs index 77fe69c43..09df97656 100644 --- a/lib/crates/fabro-api/src/demo/mod.rs +++ b/lib/crates/fabro-api/src/demo/mod.rs @@ -1289,20 +1289,20 @@ mod runs { preserve: None, devcontainer: None, local: None, - daytona: Some(fabro_daytona::DaytonaConfig { + daytona: Some(fabro_sandbox::daytona::DaytonaConfig { auto_stop_interval: Some(60), labels: Some(std::collections::HashMap::from([( "project".into(), "api-server".into(), )])), - snapshot: Some(fabro_daytona::DaytonaSnapshotConfig { + snapshot: Some(fabro_sandbox::daytona::DaytonaSnapshotConfig { name: "api-server-dev".into(), cpu: Some(4), memory: Some(8), disk: Some(10), dockerfile: None, }), - network: Some(fabro_daytona::DaytonaNetwork::Block), + network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block), skip_clone: false, }), exe: None, @@ -1463,12 +1463,12 @@ mod workflows { preserve: None, devcontainer: None, local: None, - daytona: Some(fabro_daytona::DaytonaConfig { + daytona: Some(fabro_sandbox::daytona::DaytonaConfig { auto_stop_interval: Some(60), labels: Some(std::collections::HashMap::from([ ("project".into(), "fix-build".into()), ])), - snapshot: Some(fabro_daytona::DaytonaSnapshotConfig { + snapshot: Some(fabro_sandbox::daytona::DaytonaSnapshotConfig { name: "fix-build-dev".into(), cpu: Some(4), memory: Some(8), @@ -1536,13 +1536,13 @@ mod workflows { preserve: None, devcontainer: None, local: None, - daytona: Some(fabro_daytona::DaytonaConfig { + daytona: Some(fabro_sandbox::daytona::DaytonaConfig { auto_stop_interval: Some(120), labels: Some(std::collections::HashMap::from([ ("project".into(), "implement".into()), ("team".into(), "engineering".into()), ])), - snapshot: Some(fabro_daytona::DaytonaSnapshotConfig { + snapshot: Some(fabro_sandbox::daytona::DaytonaSnapshotConfig { name: "implement-dev".into(), cpu: Some(4), memory: Some(8), @@ -1621,13 +1621,13 @@ mod workflows { preserve: None, devcontainer: None, local: None, - daytona: Some(fabro_daytona::DaytonaConfig { + daytona: Some(fabro_sandbox::daytona::DaytonaConfig { auto_stop_interval: Some(120), labels: Some(std::collections::HashMap::from([ ("project".into(), "sync-drift".into()), ("team".into(), "platform".into()), ])), - snapshot: Some(fabro_daytona::DaytonaSnapshotConfig { + snapshot: Some(fabro_sandbox::daytona::DaytonaSnapshotConfig { name: "sync-drift-dev".into(), cpu: Some(2), memory: Some(4), @@ -1697,13 +1697,13 @@ mod workflows { preserve: None, devcontainer: None, local: None, - daytona: Some(fabro_daytona::DaytonaConfig { + daytona: Some(fabro_sandbox::daytona::DaytonaConfig { auto_stop_interval: Some(180), labels: Some(std::collections::HashMap::from([ ("project".into(), "expand".into()), ("team".into(), "product".into()), ])), - snapshot: Some(fabro_daytona::DaytonaSnapshotConfig { + snapshot: Some(fabro_sandbox::daytona::DaytonaSnapshotConfig { name: "expand-dev".into(), cpu: Some(2), memory: Some(4), @@ -3273,11 +3273,11 @@ mod settings { preserve: None, devcontainer: None, local: None, - daytona: Some(fabro_daytona::DaytonaConfig { + daytona: Some(fabro_sandbox::daytona::DaytonaConfig { auto_stop_interval: Some(60), labels: None, snapshot: None, - network: Some(fabro_daytona::DaytonaNetwork::Block), + network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block), skip_clone: false, }), exe: None, diff --git a/lib/crates/fabro-api/tests/openapi_conformance.rs b/lib/crates/fabro-api/tests/openapi_conformance.rs index 2ff80327e..289912d81 100644 --- a/lib/crates/fabro-api/tests/openapi_conformance.rs +++ b/lib/crates/fabro-api/tests/openapi_conformance.rs @@ -10,9 +10,9 @@ use fabro_api::server::{build_router, create_app_state}; use fabro_api::server_config::*; use fabro_config::run::*; use fabro_config::sandbox::SandboxConfig; -use fabro_daytona::*; use fabro_hooks::*; use fabro_interview::Interviewer; +use fabro_sandbox::daytona::*; use fabro_workflows::handler::exit::ExitHandler; use fabro_workflows::handler::start::StartHandler; use fabro_workflows::handler::HandlerRegistry; @@ -307,7 +307,7 @@ fn fully_populated_server_config() -> ServerConfig { network: Some(DaytonaNetwork::Block), skip_clone: false, }), - exe: Some(fabro_exe::ExeConfig { image: None }), + exe: Some(fabro_sandbox::exe::ExeConfig { image: None }), ssh: None, env: Some(Default::default()), }), diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index adc4eb6bf..aa273a8ed 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [features] default = [] server = ["dep:fabro-api"] -exedev = ["dep:fabro-exe", "fabro-config/exedev", "fabro-workflows/exedev"] +exedev = ["fabro-sandbox/exe", "fabro-config/exedev", "fabro-workflows/exedev"] sleep_inhibitor = ["dep:fabro-beastie"] [dependencies] @@ -22,13 +22,11 @@ fabro-openai-oauth = { path = "../fabro-openai-oauth" } fabro-github = { path = "../fabro-github" } fabro-agent = { path = "../fabro-agent" } fabro-devcontainer = { path = "../fabro-devcontainer" } -fabro-exe = { path = "../fabro-exe", optional = true } fabro-hooks = { path = "../fabro-hooks" } fabro-interview = { path = "../fabro-interview" } fabro-mcp = { path = "../fabro-mcp" } -fabro-daytona = { path = "../fabro-daytona" } fabro-retro = { path = "../fabro-retro" } -fabro-ssh = { path = "../fabro-ssh" } +fabro-sandbox = { path = "../fabro-sandbox", features = ["ssh", "daytona"] } fabro-git-storage = { path = "../fabro-git-storage" } fabro-graphviz = { path = "../fabro-graphviz" } fabro-validate = { path = "../fabro-validate" } diff --git a/lib/crates/fabro-cli/src/commands/pr.rs b/lib/crates/fabro-cli/src/commands/pr.rs index f79029a47..ea58023db 100644 --- a/lib/crates/fabro-cli/src/commands/pr.rs +++ b/lib/crates/fabro-cli/src/commands/pr.rs @@ -350,7 +350,7 @@ async fn create_from( let cwd = std::env::current_dir().context("Failed to get current directory")?; let (origin_url, detected_branch) = - fabro_daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?; + fabro_sandbox::daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?; let base_branch = manifest .base_branch diff --git a/lib/crates/fabro-cli/src/commands/preview.rs b/lib/crates/fabro-cli/src/commands/preview.rs index 323cb5bd4..540027dbd 100644 --- a/lib/crates/fabro-cli/src/commands/preview.rs +++ b/lib/crates/fabro-cli/src/commands/preview.rs @@ -44,7 +44,7 @@ pub async fn run(args: PreviewArgs) -> Result<()> { info!(run_id = %args.run, provider = %record.provider, port = args.port, "Generating preview URL"); - let daytona = fabro_daytona::DaytonaSandbox::reconnect(name) + let daytona = fabro_sandbox::daytona::DaytonaSandbox::reconnect(name) .await .map_err(|e| anyhow::anyhow!("{e}"))?; diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs index 5b9a04f54..da58742f5 100644 --- a/lib/crates/fabro-cli/src/commands/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -281,7 +281,7 @@ fn resolve_worktree_mode( fn resolve_daytona_config( run_cfg: Option<&WorkflowRunConfig>, run_defaults: &RunDefaults, -) -> Option { +) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) .and_then(|e| e.daytona.clone()) @@ -298,7 +298,7 @@ fn resolve_daytona_config( fn resolve_exe_config( run_cfg: Option<&WorkflowRunConfig>, run_defaults: &RunDefaults, -) -> Option { +) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) .and_then(|e| e.exe.clone()) @@ -310,8 +310,8 @@ fn resolve_exe_config( /// /// Returns `None` if no git repo is detected. Credential resolution is /// handled by ExeSandbox itself via its `github_app` field. -fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option { - let (detected_url, branch) = match fabro_daytona::detect_repo_info(cwd) { +fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option { + let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) { Ok(info) => info, Err(e) => { tracing::warn!("No git repo detected for exe.dev clone: {e}"); @@ -319,14 +319,14 @@ fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option run defaults. fn resolve_ssh_config( run_cfg: Option<&WorkflowRunConfig>, run_defaults: &RunDefaults, -) -> Option { +) -> Option { run_cfg .and_then(|c| c.sandbox.as_ref()) .and_then(|e| e.ssh.clone()) @@ -337,8 +337,8 @@ fn resolve_ssh_config( /// /// Returns `None` if no git repo is detected. Credential resolution is /// handled by SshSandbox itself via its `github_app` field. -fn resolve_ssh_clone_params(cwd: &std::path::Path) -> Option { - let (detected_url, branch) = match fabro_daytona::detect_repo_info(cwd) { +fn resolve_ssh_clone_params(cwd: &std::path::Path) -> Option { + let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) { Ok(info) => info, Err(e) => { tracing::warn!("No git repo detected for SSH clone: {e}"); @@ -346,7 +346,7 @@ fn resolve_ssh_clone_params(cwd: &std::path::Path) -> Option { let config = daytona_config.clone().unwrap_or_default(); - let mut env = fabro_daytona::DaytonaSandbox::new( + let mut env = fabro_sandbox::daytona::DaytonaSandbox::new( config, github_app.clone(), Some(run_id.clone()), @@ -1079,11 +1080,11 @@ pub async fn run_command( SandboxProvider::Exe => { let clone_params = resolve_exe_clone_params(&original_cwd); - let mgmt_ssh = fabro_exe::OpensshRunner::connect_raw("exe.dev") + let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev") .await .map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?; let config = exe_config.unwrap_or_default(); - let mut env = fabro_exe::ExeSandbox::new( + let mut env = fabro_sandbox::exe::ExeSandbox::new( Box::new(mgmt_ssh), config, clone_params, @@ -1101,7 +1102,7 @@ pub async fn run_command( .clone() .ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?; let clone_params = resolve_ssh_clone_params(&original_cwd); - let mut env = fabro_ssh::SshSandbox::new( + let mut env = fabro_sandbox::ssh::SshSandbox::new( config, clone_params, Some(run_id.clone()), @@ -1824,11 +1825,11 @@ async fn run_from_branch( SandboxProvider::Exe => { let exe_config = resolve_exe_config(None, &run_defaults); let clone_params = resolve_exe_clone_params(&original_cwd); - let mgmt_ssh = fabro_exe::OpensshRunner::connect_raw("exe.dev") + let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev") .await .map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?; let config = exe_config.unwrap_or_default(); - let mut env = fabro_exe::ExeSandbox::new( + let mut env = fabro_sandbox::exe::ExeSandbox::new( Box::new(mgmt_ssh), config, clone_params, @@ -1846,7 +1847,7 @@ async fn run_from_branch( anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config") })?; let clone_params = resolve_ssh_clone_params(&original_cwd); - let mut env = fabro_ssh::SshSandbox::new( + let mut env = fabro_sandbox::ssh::SshSandbox::new( config, clone_params, Some(run_id.clone()), @@ -2179,31 +2180,40 @@ async fn run_preflight( } SandboxProvider::Daytona => { let config = daytona_config.unwrap_or_default(); - match fabro_daytona::DaytonaSandbox::new(config, github_app.clone(), None, None).await { + match fabro_sandbox::daytona::DaytonaSandbox::new( + config, + github_app.clone(), + None, + None, + ) + .await + { Ok(env) => Ok(Arc::new(env) as Arc), Err(e) => Err(format!("Daytona sandbox creation failed: {e}")), } } #[cfg(feature = "exedev")] - SandboxProvider::Exe => match fabro_exe::OpensshRunner::connect_raw("exe.dev").await { - Ok(mgmt_ssh) => { - let config = exe_config.unwrap_or_default(); - let clone_params = resolve_exe_clone_params(&original_cwd); - let env = fabro_exe::ExeSandbox::new( - Box::new(mgmt_ssh), - config, - clone_params, - None, - None, - ); - Ok(Arc::new(env) as Arc) + SandboxProvider::Exe => { + match fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev").await { + Ok(mgmt_ssh) => { + let config = exe_config.unwrap_or_default(); + let clone_params = resolve_exe_clone_params(&original_cwd); + let env = fabro_sandbox::exe::ExeSandbox::new( + Box::new(mgmt_ssh), + config, + clone_params, + None, + None, + ); + Ok(Arc::new(env) as Arc) + } + Err(e) => Err(format!("exe.dev SSH connection failed: {e}")), } - Err(e) => Err(format!("exe.dev SSH connection failed: {e}")), - }, + } SandboxProvider::Ssh => match ssh_config { Some(config) => { let clone_params = resolve_ssh_clone_params(&original_cwd); - let env = fabro_ssh::SshSandbox::new(config, clone_params, None, None); + let env = fabro_sandbox::ssh::SshSandbox::new(config, clone_params, None, None); Ok(Arc::new(env) as Arc) } None => Err("SSH sandbox requires [sandbox.ssh] config".to_string()), diff --git a/lib/crates/fabro-cli/src/commands/ssh.rs b/lib/crates/fabro-cli/src/commands/ssh.rs index 0f7a1820a..f86bdbb07 100644 --- a/lib/crates/fabro-cli/src/commands/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/ssh.rs @@ -33,7 +33,7 @@ pub async fn run(args: SshArgs) -> Result<()> { info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access"); - let daytona = fabro_daytona::DaytonaSandbox::reconnect(name) + let daytona = fabro_sandbox::daytona::DaytonaSandbox::reconnect(name) .await .map_err(|e| anyhow::anyhow!("{e}"))?; diff --git a/lib/crates/fabro-daytona/Cargo.toml b/lib/crates/fabro-daytona/Cargo.toml deleted file mode 100644 index 921a75dfe..000000000 --- a/lib/crates/fabro-daytona/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "fabro-daytona" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "Daytona cloud sandbox for Fabro agent tool operations" - -[lib] -doctest = false - -[dependencies] -fabro-agent = { path = "../fabro-agent" } -fabro-config = { path = "../fabro-config" } -fabro-github = { path = "../fabro-github" } -async-trait.workspace = true -tokio.workspace = true -tokio-util.workspace = true -daytona-sdk.workspace = true -daytona-api-client.workspace = true -base64.workspace = true -tracing.workspace = true -serde.workspace = true -chrono = { workspace = true, features = ["serde"] } -git2.workspace = true -rand.workspace = true -shlex = "1" - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util", "macros"] } -tempfile = "3" -toml.workspace = true diff --git a/lib/crates/fabro-exe/Cargo.toml b/lib/crates/fabro-exe/Cargo.toml deleted file mode 100644 index 1d03c1a3e..000000000 --- a/lib/crates/fabro-exe/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "fabro-exe" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "exe.dev VM sandbox for Fabro agent tool operations" - -[lib] -doctest = false - -[dependencies] -fabro-agent = { path = "../fabro-agent" } -fabro-config = { path = "../fabro-config", features = ["exedev"] } -fabro-github = { path = "../fabro-github" } -async-trait.workspace = true -tokio.workspace = true -tokio-util.workspace = true -openssh.workspace = true -reqwest.workspace = true -serde_json.workspace = true -base64.workspace = true -tracing.workspace = true -serde.workspace = true -shlex = "1" - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util", "macros"] } -tempfile = "3" diff --git a/lib/crates/fabro-exe/tests/integration.rs b/lib/crates/fabro-exe/tests/integration.rs deleted file mode 100644 index 5c08227a6..000000000 --- a/lib/crates/fabro-exe/tests/integration.rs +++ /dev/null @@ -1,49 +0,0 @@ -use fabro_agent::sandbox::Sandbox; -use fabro_exe::{ExeConfig, ExeSandbox, OpensshRunner}; - -/// Full lifecycle test against a real exe.dev account. -/// Requires SSH agent with exe.dev credentials. -/// -/// Run with: cargo test -p arc-exe -- --ignored -#[tokio::test] -#[ignore] -async fn exe_sandbox_full_lifecycle() { - let mgmt_ssh = OpensshRunner::connect_raw("exe.dev") - .await - .expect("SSH to exe.dev failed — is your SSH agent running?"); - - let sandbox = ExeSandbox::new(Box::new(mgmt_ssh), ExeConfig::default(), None, None, None); - - // Initialize (creates VM) - sandbox.initialize().await.unwrap(); - assert!(!sandbox.sandbox_info().is_empty()); - assert_eq!(sandbox.platform(), "linux"); - - // exec_command - let result = sandbox - .exec_command("echo hello", 10_000, None, None, None) - .await - .unwrap(); - assert_eq!(result.stdout.trim(), "hello"); - assert_eq!(result.exit_code, 0); - - // write_file + read_file - sandbox - .write_file("test.txt", "line1\nline2\nline3") - .await - .unwrap(); - let content = sandbox.read_file("test.txt", None, None).await.unwrap(); - assert!(content.contains("1 | line1")); - assert!(content.contains("2 | line2")); - - // file_exists - assert!(sandbox.file_exists("test.txt").await.unwrap()); - assert!(!sandbox.file_exists("nonexistent.txt").await.unwrap()); - - // delete_file - sandbox.delete_file("test.txt").await.unwrap(); - assert!(!sandbox.file_exists("test.txt").await.unwrap()); - - // Cleanup (destroys VM) - sandbox.cleanup().await.unwrap(); -} diff --git a/lib/crates/fabro-sandbox/Cargo.toml b/lib/crates/fabro-sandbox/Cargo.toml new file mode 100644 index 000000000..92727fbbd --- /dev/null +++ b/lib/crates/fabro-sandbox/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "fabro-sandbox" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Sandbox trait and implementations for Fabro agent execution environments" + +[features] +default = ["local"] +local = ["dep:glob", "dep:libc"] +docker = ["dep:bollard", "dep:tar", "dep:futures"] +ssh = ["dep:openssh", "dep:fabro-github", "dep:fabro-config"] +exe = ["ssh", "fabro-config/exedev"] +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 = [] + +[lib] +doctest = false + +[dependencies] +async-trait.workspace = true +tokio.workspace = true +tokio-util.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +base64.workspace = true +shlex = "1" + +# local +glob = { version = "0.3", optional = true } + +# docker +bollard = { workspace = true, optional = true } +tar = { workspace = true, optional = true } +futures = { workspace = true, optional = true } + +# ssh / exe / daytona +openssh = { workspace = true, optional = true } +fabro-config = { path = "../fabro-config", optional = true } +fabro-github = { path = "../fabro-github", optional = true } + +# sprites +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 } + +[target.'cfg(unix)'.dependencies] +libc = { version = "0.2", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util", "macros"] } +tempfile = "3" +uuid.workspace = true +serde_json.workspace = true +toml.workspace = true diff --git a/lib/crates/fabro-daytona/src/lib.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs similarity index 99% rename from lib/crates/fabro-daytona/src/lib.rs rename to lib/crates/fabro-sandbox/src/daytona/mod.rs index fce7610be..8598c29b0 100644 --- a/lib/crates/fabro-daytona/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -2,11 +2,12 @@ use std::collections::HashMap; use std::path::Path; use std::time::Instant; -use async_trait::async_trait; -use fabro_agent::sandbox::{ +use crate::shell_quote; +use crate::{ format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, }; +use async_trait::async_trait; use fabro_github::GitHubAppCredentials; use rand::Rng; @@ -1102,13 +1103,6 @@ impl Sandbox for DaytonaSandbox { /// /// Uses base64 encoding (matching the TypeScript/Python/Ruby Daytona SDKs) /// to avoid shell escaping issues with quotes and special characters. -fn shell_quote(s: &str) -> String { - shlex::try_quote(s).map_or_else( - |_| format!("'{}'", s.replace('\'', "'\\''")), - |q| q.to_string(), - ) -} - fn wrap_bash_command(command: &str) -> String { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(command); diff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/crates/fabro-sandbox/src/docker.rs new file mode 100644 index 000000000..0a35bb1b1 --- /dev/null +++ b/lib/crates/fabro-sandbox/src/docker.rs @@ -0,0 +1,958 @@ +use crate::{ + format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, +}; +use async_trait::async_trait; +use bollard::container::{ + Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions, + StopContainerOptions, UploadToContainerOptions, +}; +use bollard::exec::{CreateExecOptions, StartExecResults}; +use bollard::image::CreateImageOptions; +use bollard::Docker; +use futures::StreamExt; +use std::collections::HashMap; +use std::time::Instant; +use tokio_util::sync::CancellationToken; + +/// Configuration for a Docker-based sandbox. +pub struct DockerSandboxConfig { + /// Docker image to use. Default: `"fabro-agent:latest"`. + pub image: String, + /// Host directory to bind-mount into the container. + pub host_working_directory: String, + /// Mount point inside the container. Default: `"/workspace"`. + pub container_mount_point: String, + /// Docker network mode. Default: `Some("bridge")`. + pub network_mode: Option, + /// Additional `"host_path:container_path"` bind mounts. + pub extra_mounts: Vec, + /// Memory limit in bytes. `None` = unlimited. + pub memory_limit: Option, + /// CPU quota (microseconds per 100ms period). `None` = unlimited. + pub cpu_quota: Option, + /// Whether to pull the image if not found locally. Default: `true`. + pub auto_pull: bool, + /// Additional `KEY=VALUE` environment variables for the container. + pub env_vars: Vec, +} + +impl Default for DockerSandboxConfig { + fn default() -> Self { + Self { + image: "fabro-agent:latest".to_string(), + host_working_directory: String::new(), + container_mount_point: "/workspace".to_string(), + network_mode: Some("bridge".to_string()), + extra_mounts: Vec::new(), + memory_limit: None, + cpu_quota: None, + auto_pull: true, + env_vars: Vec::new(), + } + } +} + +/// Sandbox that runs all operations inside a Docker container. +/// +/// The host working directory is bind-mounted at `container_mount_point`. All file +/// operations, commands, grep, and glob execute inside the container via `docker exec`. +pub struct DockerSandbox { + docker: Docker, + config: DockerSandboxConfig, + container_id: tokio::sync::OnceCell, + cached_platform: std::sync::OnceLock, + cached_os_version: std::sync::OnceLock, + rg_available: tokio::sync::OnceCell, + event_callback: Option, +} + +impl DockerSandbox { + /// Creates a new `DockerSandbox`. + /// + /// Validates Docker daemon connectivity but does NOT create a container. + /// Call `initialize()` to create and start the container. + pub fn new(config: DockerSandboxConfig) -> Result { + let docker = Docker::connect_with_local_defaults() + .map_err(|e| format!("Failed to connect to Docker daemon: {e}"))?; + Ok(Self { + docker, + config, + container_id: tokio::sync::OnceCell::new(), + cached_platform: std::sync::OnceLock::new(), + cached_os_version: std::sync::OnceLock::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 container_id(&self) -> Result<&str, String> { + self.container_id + .get() + .map(String::as_str) + .ok_or_else(|| "Container not initialized — call initialize() first".to_string()) + } + + /// Resolves a path for use inside the container. + /// Absolute paths are used as-is; relative paths are prepended with the mount point. + fn resolve_container_path(&self, path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("{}/{path}", self.config.container_mount_point) + } + } + + /// Maps a container-space path to the corresponding host-space path + /// using the bind-mount configuration. + fn container_to_host_path(&self, remote_path: &str) -> Result { + let container_path = self.resolve_container_path(remote_path); + if container_path.starts_with(&self.config.container_mount_point) { + let relative = &container_path[self.config.container_mount_point.len()..]; + let relative = relative.strip_prefix('/').unwrap_or(relative); + Ok(std::path::PathBuf::from(&self.config.host_working_directory).join(relative)) + } else { + Err(format!( + "Path {container_path} is outside the bind-mounted directory {}", + self.config.container_mount_point + )) + } + } + + /// Executes a command inside the container, returning `(stdout, stderr, exit_code)`. + async fn docker_exec( + &self, + cmd: Vec, + working_dir: Option<&str>, + env: Option>, + ) -> Result<(String, String, i32), String> { + let container_id = self.container_id()?; + + let exec_opts = CreateExecOptions { + cmd: Some(cmd), + attach_stdout: Some(true), + attach_stderr: Some(true), + working_dir: working_dir.map(ToString::to_string), + env: env.map(|e| e.into_iter().collect()), + ..Default::default() + }; + + let exec_instance = self + .docker + .create_exec(container_id, exec_opts) + .await + .map_err(|e| format!("Failed to create exec: {e}"))?; + + let start_result = self + .docker + .start_exec(&exec_instance.id, None) + .await + .map_err(|e| format!("Failed to start exec: {e}"))?; + + let mut stdout = String::new(); + let mut stderr = String::new(); + + if let StartExecResults::Attached { mut output, .. } = start_result { + while let Some(chunk) = output.next().await { + match chunk { + Ok(bollard::container::LogOutput::StdOut { message }) => { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + Ok(bollard::container::LogOutput::StdErr { message }) => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + Ok(_) => {} + Err(e) => return Err(format!("Error reading exec output: {e}")), + } + } + } + + let inspect = self + .docker + .inspect_exec(&exec_instance.id) + .await + .map_err(|e| format!("Failed to inspect exec: {e}"))?; + + let exit_code = inspect.exit_code.unwrap_or(-1) as i32; + Ok((stdout, stderr, exit_code)) + } + + /// Runs a shell command inside the container with timeout and cancellation support. + async fn docker_exec_shell( + &self, + command: &str, + timeout_ms: u64, + working_dir: Option<&str>, + env_vars: Option<&HashMap>, + cancel_token: Option, + ) -> Result { + let start = Instant::now(); + + let effective_dir = working_dir.map_or_else( + || self.config.container_mount_point.clone(), + ToString::to_string, + ); + + let env: Option> = + env_vars.map(|vars| vars.iter().map(|(k, v)| format!("{k}={v}")).collect()); + + let cmd = vec![ + "/bin/bash".to_string(), + "-c".to_string(), + command.to_string(), + ]; + + let timeout_duration = std::time::Duration::from_millis(timeout_ms); + let token = cancel_token.unwrap_or_default(); + + tokio::select! { + result = self.docker_exec(cmd, Some(&effective_dir), env) => { + let (stdout, stderr, exit_code) = result?; + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + Ok(ExecResult { + stdout, + stderr, + exit_code, + timed_out: false, + duration_ms, + }) + } + () = tokio::time::sleep(timeout_duration) => { + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + Ok(ExecResult { + stdout: String::new(), + stderr: "Command timed out".to_string(), + exit_code: -1, + timed_out: true, + duration_ms, + }) + } + () = token.cancelled() => { + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + Ok(ExecResult { + stdout: String::new(), + stderr: "Command cancelled".to_string(), + exit_code: -1, + timed_out: true, + duration_ms, + }) + } + } + } + + /// Pulls the configured image if `auto_pull` is enabled and the image is not found locally. + async fn ensure_image(&self) -> Result<(), String> { + if !self.config.auto_pull { + return Ok(()); + } + + // Check if image exists locally + if self.docker.inspect_image(&self.config.image).await.is_ok() { + return Ok(()); + } + + // Parse image into repo and tag + let (repo, tag) = if let Some((r, t)) = self.config.image.rsplit_once(':') { + (r.to_string(), t.to_string()) + } else { + (self.config.image.clone(), "latest".to_string()) + }; + + let opts = CreateImageOptions { + from_image: repo, + tag, + ..Default::default() + }; + + let mut stream = self.docker.create_image(Some(opts), None, None); + while let Some(result) = stream.next().await { + result.map_err(|e| format!("Failed to pull image {}: {e}", self.config.image))?; + } + + Ok(()) + } +} + +#[async_trait] +impl Sandbox for DockerSandbox { + async fn download_file_to_local( + &self, + remote_path: &str, + local_path: &std::path::Path, + ) -> Result<(), String> { + let host_path = self.container_to_host_path(remote_path)?; + + 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::copy(&host_path, local_path).await.map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + host_path.display(), + local_path.display() + ) + })?; + Ok(()) + } + + async fn upload_file_from_local( + &self, + local_path: &std::path::Path, + remote_path: &str, + ) -> Result<(), String> { + let host_path = self.container_to_host_path(remote_path)?; + + if let Some(parent) = host_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create parent dirs: {e}"))?; + } + tokio::fs::copy(local_path, &host_path).await.map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + local_path.display(), + host_path.display() + ) + })?; + Ok(()) + } + + async fn initialize(&self) -> Result<(), String> { + self.emit(SandboxEvent::Initializing { + provider: "docker".into(), + }); + let init_start = Instant::now(); + + self.emit(SandboxEvent::SnapshotPulling { + name: self.config.image.clone(), + }); + let pull_start = Instant::now(); + if let Err(e) = self.ensure_image().await { + let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: "docker".into(), + error: e.clone(), + duration_ms, + }); + return Err(e); + } + let pull_duration = u64::try_from(pull_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::SnapshotPulled { + name: self.config.image.clone(), + duration_ms: pull_duration, + }); + + let mut binds = vec![format!( + "{}:{}", + self.config.host_working_directory, self.config.container_mount_point + )]; + for extra in &self.config.extra_mounts { + binds.push(extra.clone()); + } + + let host_config = bollard::models::HostConfig { + binds: Some(binds), + network_mode: self.config.network_mode.clone(), + memory: self.config.memory_limit, + cpu_quota: self.config.cpu_quota, + ..Default::default() + }; + + let container_config = Config { + image: Some(self.config.image.clone()), + cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]), + working_dir: Some(self.config.container_mount_point.clone()), + env: if self.config.env_vars.is_empty() { + None + } else { + Some(self.config.env_vars.clone()) + }, + host_config: Some(host_config), + ..Default::default() + }; + + let container = self + .docker + .create_container(None::>, container_config) + .await + .map_err(|e| format!("Failed to create container: {e}"))?; + + let id = container.id.clone(); + + self.docker + .start_container(&id, None::>) + .await + .map_err(|e| format!("Failed to start container: {e}"))?; + + self.container_id + .set(id) + .map_err(|_| "Container already initialized".to_string())?; + + // Verify container is running + let (stdout, _, exit_code) = self + .docker_exec(vec!["echo".to_string(), "ready".to_string()], None, None) + .await?; + + if exit_code != 0 || !stdout.contains("ready") { + return Err("Container health check failed".to_string()); + } + + // Cache platform info + let (uname_output, _, _) = self + .docker_exec(vec!["uname".to_string(), "-r".to_string()], None, None) + .await?; + + let _ = self.cached_platform.set("linux".to_string()); + let _ = self + .cached_os_version + .set(format!("linux {}", uname_output.trim())); + + let init_duration = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::Ready { + provider: "docker".into(), + duration_ms: init_duration, + name: None, + cpu: None, + memory: None, + url: None, + }); + + Ok(()) + } + + async fn cleanup(&self) -> Result<(), String> { + self.emit(SandboxEvent::CleanupStarted { + provider: "docker".into(), + }); + let start = Instant::now(); + + let container_id = match self.container_id.get() { + Some(id) => id.clone(), + None => { + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::CleanupCompleted { + provider: "docker".into(), + duration_ms, + }); + return Ok(()); + } + }; + + // Stop with 5-second grace period; ignore "not running" errors + let stop_opts = StopContainerOptions { t: 5 }; + let _ = self + .docker + .stop_container(&container_id, Some(stop_opts)) + .await; + + // Force-remove; ignore "no such container" errors + let remove_opts = RemoveContainerOptions { + force: true, + ..Default::default() + }; + let _ = self + .docker + .remove_container(&container_id, Some(remove_opts)) + .await; + + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::CleanupCompleted { + provider: "docker".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 dir = working_dir.map(|d| self.resolve_container_path(d)); + self.docker_exec_shell(command, timeout_ms, dir.as_deref(), env_vars, cancel_token) + .await + } + + async fn read_file( + &self, + path: &str, + offset: Option, + limit: Option, + ) -> Result { + let container_path = self.resolve_container_path(path); + let (stdout, stderr, exit_code) = self + .docker_exec(vec!["cat".to_string(), container_path.clone()], None, None) + .await?; + + if exit_code != 0 { + return Err(format!("Failed to read {container_path}: {stderr}")); + } + + Ok(format_lines_numbered(&stdout, offset, limit)) + } + + async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { + let container_path = self.resolve_container_path(path); + let container_id = self.container_id()?; + + // Ensure parent directory exists + if let Some(parent) = std::path::Path::new(&container_path).parent() { + let parent_str = parent.to_string_lossy(); + let (_, stderr, exit_code) = self + .docker_exec( + vec![ + "mkdir".to_string(), + "-p".to_string(), + parent_str.to_string(), + ], + None, + None, + ) + .await?; + if exit_code != 0 { + return Err(format!( + "Failed to create parent dirs for {container_path}: {stderr}" + )); + } + } + + // Build an in-memory tar archive to upload via bollard API. + // This avoids shell escaping issues with special characters in content. + let mut tar_builder = tar::Builder::new(Vec::new()); + let file_name = std::path::Path::new(&container_path) + .file_name() + .ok_or_else(|| format!("Invalid path: {container_path}"))? + .to_string_lossy() + .to_string(); + + let content_bytes = content.as_bytes(); + let mut header = tar::Header::new_gnu(); + header + .set_path(&file_name) + .map_err(|e| format!("Failed to set tar path: {e}"))?; + header.set_size(content_bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + + tar_builder + .append(&header, content_bytes) + .map_err(|e| format!("Failed to build tar archive: {e}"))?; + + let tar_bytes = tar_builder + .into_inner() + .map_err(|e| format!("Failed to finalize tar archive: {e}"))?; + + let parent_dir = std::path::Path::new(&container_path) + .parent() + .map_or_else(|| "/".to_string(), |p| p.to_string_lossy().to_string()); + + let upload_opts = UploadToContainerOptions { + path: parent_dir, + ..Default::default() + }; + + self.docker + .upload_to_container(container_id, Some(upload_opts), tar_bytes.into()) + .await + .map_err(|e| format!("Failed to upload file to container: {e}")) + } + + async fn delete_file(&self, path: &str) -> Result<(), String> { + let container_path = self.resolve_container_path(path); + let (_, stderr, exit_code) = self + .docker_exec( + vec!["rm".to_string(), "-f".to_string(), container_path.clone()], + None, + None, + ) + .await?; + + if exit_code != 0 { + return Err(format!("Failed to delete {container_path}: {stderr}")); + } + Ok(()) + } + + async fn file_exists(&self, path: &str) -> Result { + let container_path = self.resolve_container_path(path); + let (_, _, exit_code) = self + .docker_exec( + vec!["test".to_string(), "-e".to_string(), container_path], + None, + None, + ) + .await?; + + Ok(exit_code == 0) + } + + async fn list_directory( + &self, + path: &str, + depth: Option, + ) -> Result, String> { + let container_path = self.resolve_container_path(path); + let max_depth = depth.unwrap_or(1); + + // Use find with -printf for structured output: type, size, relative path + let (stdout, stderr, exit_code) = self + .docker_exec( + vec![ + "find".to_string(), + container_path.clone(), + "-mindepth".to_string(), + "1".to_string(), + "-maxdepth".to_string(), + max_depth.to_string(), + "-printf".to_string(), + "%y\t%s\t%P\n".to_string(), + ], + None, + None, + ) + .await?; + + if exit_code != 0 { + return Err(format!( + "Failed to list directory {container_path}: {stderr}" + )); + } + + let mut entries: Vec = 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 container_path = self.resolve_container_path(path); + + // Detect ripgrep availability (cached) + let use_rg = *self + .rg_available + .get_or_init(|| async { + let result = self + .docker_exec(vec!["which".to_string(), "rg".to_string()], None, None) + .await; + matches!(result, Ok((_, _, 0))) + }) + .await; + + let command = if use_rg { + let mut args = vec!["rg".to_string(), "-n".to_string()]; + if options.case_insensitive { + args.push("-i".to_string()); + } + if let Some(ref glob_filter) = options.glob_filter { + args.push("--glob".to_string()); + args.push(glob_filter.clone()); + } + if let Some(max) = options.max_results { + args.push("-m".to_string()); + args.push(max.to_string()); + } + args.push(pattern.to_string()); + args.push(container_path); + args.join(" ") + } else { + let mut args = vec!["grep".to_string(), "-rn".to_string()]; + if options.case_insensitive { + args.push("-i".to_string()); + } + if let Some(ref glob_filter) = options.glob_filter { + args.push("--include".to_string()); + args.push(glob_filter.clone()); + } + if let Some(max) = options.max_results { + args.push("-m".to_string()); + args.push(max.to_string()); + } + args.push(format!("'{pattern}'")); + args.push(container_path); + args.join(" ") + }; + + // Run through shell so that quoting works correctly + let result = self + .docker_exec_shell(&command, 30_000, None, None, None) + .await?; + + let results: Vec = result + .stdout + .lines() + .map(String::from) + .filter(|l| !l.is_empty()) + .collect(); + + Ok(results) + } + + async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { + let base_dir = path.map_or_else( + || self.config.container_mount_point.clone(), + |p| self.resolve_container_path(p), + ); + + let full_pattern = if pattern.starts_with('/') { + pattern.to_string() + } else { + format!("{base_dir}/{pattern}") + }; + + // Use bash globbing with stat for mtime-descending sort + let script = format!( + "shopt -s nullglob globstar; for f in {full_pattern}; do stat --format='%Y %n' \"$f\" 2>/dev/null; done | sort -rn | cut -d' ' -f2-" + ); + + let result = self + .docker_exec_shell(&script, 30_000, None, None, None) + .await?; + + let results: Vec = result + .stdout + .lines() + .map(String::from) + .filter(|l| !l.is_empty()) + .collect(); + + Ok(results) + } + + fn working_directory(&self) -> &str { + &self.config.container_mount_point + } + + fn platform(&self) -> &str { + self.cached_platform.get().map_or("linux", String::as_str) + } + + fn os_version(&self) -> String { + self.cached_os_version + .get() + .cloned() + .unwrap_or_else(|| "linux".to_string()) + } + + fn sandbox_info(&self) -> String { + self.container_id.get().cloned().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + fn require_docker() -> Docker { + Docker::connect_with_local_defaults().expect("Docker not available — skipping") + } + + fn test_config(host_dir: &str) -> DockerSandboxConfig { + DockerSandboxConfig { + host_working_directory: host_dir.to_string(), + auto_pull: false, + ..Default::default() + } + } + + #[tokio::test] + #[ignore] + async fn full_lifecycle() { + let _docker = require_docker(); + let host_dir = + std::env::temp_dir().join(format!("docker_env_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&host_dir).unwrap(); + + let config = test_config(host_dir.to_str().unwrap()); + let env: Arc = Arc::new(DockerSandbox::new(config).unwrap()); + + // Initialize + env.initialize().await.unwrap(); + + // Platform and OS version + assert_eq!(env.platform(), "linux"); + assert!(env.os_version().starts_with("linux ")); + + // exec_command + let result = env + .exec_command("echo hello", 5000, None, None, None) + .await + .unwrap(); + assert_eq!(result.stdout.trim(), "hello"); + assert_eq!(result.exit_code, 0); + assert!(!result.timed_out); + + // write_file + read_file + env.write_file("test.txt", "line1\nline2\nline3") + .await + .unwrap(); + let content = env.read_file("test.txt", None, None).await.unwrap(); + assert!(content.contains("1 | line1")); + assert!(content.contains("2 | line2")); + assert!(content.contains("3 | line3")); + + // file_exists + assert!(env.file_exists("test.txt").await.unwrap()); + assert!(!env.file_exists("nonexistent.txt").await.unwrap()); + + // list_directory + let entries = env.list_directory(".", None).await.unwrap(); + assert!(entries.iter().any(|e| e.name == "test.txt")); + + // grep + let grep_results = env + .grep("line2", "test.txt", &GrepOptions::default()) + .await + .unwrap(); + assert_eq!(grep_results.len(), 1); + assert!(grep_results[0].contains("line2")); + + // glob + let glob_results = env.glob("*.txt", None).await.unwrap(); + assert!(glob_results.iter().any(|p| p.contains("test.txt"))); + + // delete_file + env.delete_file("test.txt").await.unwrap(); + assert!(!env.file_exists("test.txt").await.unwrap()); + + // Cleanup + env.cleanup().await.unwrap(); + std::fs::remove_dir_all(&host_dir).ok(); + } + + #[tokio::test] + #[ignore] + async fn timeout_handling() { + let _docker = require_docker(); + let host_dir = + std::env::temp_dir().join(format!("docker_timeout_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&host_dir).unwrap(); + + let config = test_config(host_dir.to_str().unwrap()); + let env = DockerSandbox::new(config).unwrap(); + env.initialize().await.unwrap(); + + let result = env + .exec_command("sleep 60", 1000, None, None, None) + .await + .unwrap(); + assert!(result.timed_out); + assert_eq!(result.exit_code, -1); + + env.cleanup().await.unwrap(); + std::fs::remove_dir_all(&host_dir).ok(); + } + + #[tokio::test] + #[ignore] + async fn special_characters_in_write() { + let _docker = require_docker(); + let host_dir = + std::env::temp_dir().join(format!("docker_special_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&host_dir).unwrap(); + + let config = test_config(host_dir.to_str().unwrap()); + let env = DockerSandbox::new(config).unwrap(); + env.initialize().await.unwrap(); + + let content = "hello \"world\"\nit's a `test`\nprice: $100\nbackslash: \\\nnewline above"; + env.write_file("special.txt", content).await.unwrap(); + + // Read raw content back via cat to verify exact match + let result = env + .exec_command("cat /workspace/special.txt", 5000, None, None, None) + .await + .unwrap(); + assert_eq!(result.stdout, content); + + env.cleanup().await.unwrap(); + std::fs::remove_dir_all(&host_dir).ok(); + } + + #[tokio::test] + #[ignore] + async fn path_resolution() { + let _docker = require_docker(); + let host_dir = + std::env::temp_dir().join(format!("docker_path_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&host_dir).unwrap(); + + let config = test_config(host_dir.to_str().unwrap()); + let env = DockerSandbox::new(config).unwrap(); + env.initialize().await.unwrap(); + + // Relative path resolves to container_mount_point + env.write_file("relative.txt", "relative").await.unwrap(); + assert!(env.file_exists("relative.txt").await.unwrap()); + assert!(env.file_exists("/workspace/relative.txt").await.unwrap()); + + // Absolute path used as-is + env.write_file("/tmp/absolute.txt", "absolute") + .await + .unwrap(); + assert!(env.file_exists("/tmp/absolute.txt").await.unwrap()); + + env.cleanup().await.unwrap(); + std::fs::remove_dir_all(&host_dir).ok(); + } + + #[tokio::test] + #[ignore] + async fn cleanup_idempotent() { + let _docker = require_docker(); + let host_dir = + std::env::temp_dir().join(format!("docker_cleanup_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&host_dir).unwrap(); + + let config = test_config(host_dir.to_str().unwrap()); + let env = DockerSandbox::new(config).unwrap(); + env.initialize().await.unwrap(); + + // First cleanup + env.cleanup().await.unwrap(); + // Second cleanup should not error + env.cleanup().await.unwrap(); + + std::fs::remove_dir_all(&host_dir).ok(); + } +} diff --git a/lib/crates/fabro-exe/src/lib.rs b/lib/crates/fabro-sandbox/src/exe/mod.rs similarity index 99% rename from lib/crates/fabro-exe/src/lib.rs rename to lib/crates/fabro-sandbox/src/exe/mod.rs index 119e99998..3a371f607 100644 --- a/lib/crates/fabro-exe/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/exe/mod.rs @@ -4,12 +4,13 @@ use std::collections::HashMap; use std::path::Path; use std::time::Instant; -use async_trait::async_trait; -use base64::Engine; -use fabro_agent::sandbox::{ +use crate::shell_quote; +use crate::{ format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, }; +use async_trait::async_trait; +use base64::Engine; use tokio_util::sync::CancellationToken; pub use openssh_runner::OpensshRunner; @@ -41,13 +42,6 @@ impl SshRunner for NoopSshRunner { } } -pub(crate) fn shell_quote(s: &str) -> String { - shlex::try_quote(s).map_or_else( - |_| format!("'{}'", s.replace('\'', "'\\''")), - |q| q.to_string(), - ) -} - /// Factory function type for creating data-plane SSH runners. type DataSshFactory = Box< dyn Fn( diff --git a/lib/crates/fabro-exe/src/openssh_runner.rs b/lib/crates/fabro-sandbox/src/exe/openssh_runner.rs similarity index 98% rename from lib/crates/fabro-exe/src/openssh_runner.rs rename to lib/crates/fabro-sandbox/src/exe/openssh_runner.rs index 1666021fe..59ed7bfd1 100644 --- a/lib/crates/fabro-exe/src/openssh_runner.rs +++ b/lib/crates/fabro-sandbox/src/exe/openssh_runner.rs @@ -1,7 +1,8 @@ use async_trait::async_trait; use openssh::{KnownHosts, Session}; -use crate::{shell_quote, SshOutput, SshRunner}; +use super::{SshOutput, SshRunner}; +use crate::shell_quote; /// Real SSH implementation using the `openssh` crate (multiplexed connections). pub struct OpensshRunner { diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs new file mode 100644 index 000000000..838e756ef --- /dev/null +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -0,0 +1,37 @@ +pub mod sandbox; + +pub mod read_guard; + +#[cfg(feature = "local")] +pub mod local; + +#[cfg(feature = "docker")] +pub mod docker; + +#[cfg(feature = "sprites")] +pub mod sprites; + +#[cfg(feature = "ssh")] +pub mod ssh; + +#[cfg(feature = "exe")] +pub mod exe; + +#[cfg(feature = "daytona")] +pub mod daytona; + +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; + +pub use sandbox::{ + format_lines_numbered, shell_quote, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, +}; + +pub use read_guard::ReadBeforeWriteSandbox; + +#[cfg(feature = "local")] +pub use local::LocalSandbox; + +#[cfg(feature = "docker")] +pub use docker::{DockerSandbox, DockerSandboxConfig}; diff --git a/lib/crates/fabro-sandbox/src/local.rs b/lib/crates/fabro-sandbox/src/local.rs new file mode 100644 index 000000000..6d52bfab8 --- /dev/null +++ b/lib/crates/fabro-sandbox/src/local.rs @@ -0,0 +1,889 @@ +use crate::{ + format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, + SandboxEventCallback, +}; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; +use std::time::Instant; +use tokio::io::AsyncReadExt; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; + +pub struct LocalSandbox { + working_directory: PathBuf, + event_callback: Option, + rg_available: std::sync::OnceLock, +} + +impl LocalSandbox { + #[must_use] + pub fn new(working_directory: PathBuf) -> Self { + Self { + working_directory, + event_callback: None, + rg_available: std::sync::OnceLock::new(), + } + } + + 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); + } + } + + const ENV_SAFELIST: &'static [&'static str] = &[ + "PATH", + "HOME", + "USER", + "SHELL", + "LANG", + "TERM", + "TMPDIR", + "GOPATH", + "CARGO_HOME", + "NVM_DIR", + ]; + + fn should_filter_env_var(key: &str) -> bool { + if Self::ENV_SAFELIST.contains(&key) { + return false; + } + let lower = key.to_lowercase(); + lower.ends_with("_api_key") + || lower.ends_with("_secret") + || lower.ends_with("_token") + || lower.ends_with("_password") + || lower.ends_with("_credential") + } + + fn resolve_path(&self, path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + p.to_path_buf() + } else { + self.working_directory.join(p) + } + } +} + +#[async_trait] +impl Sandbox for LocalSandbox { + async fn read_file( + &self, + path: &str, + offset: Option, + limit: Option, + ) -> Result { + let full_path = self.resolve_path(path); + let content = tokio::fs::read_to_string(&full_path) + .await + .map_err(|e| format!("Failed to read {}: {e}", full_path.display()))?; + + Ok(format_lines_numbered(&content, offset, limit)) + } + + async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { + let full_path = self.resolve_path(path); + if let Some(parent) = full_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create parent dirs: {e}"))?; + } + tokio::fs::write(&full_path, content) + .await + .map_err(|e| format!("Failed to write {}: {e}", full_path.display())) + } + + async fn delete_file(&self, path: &str) -> Result<(), String> { + let full_path = self.resolve_path(path); + tokio::fs::remove_file(&full_path) + .await + .map_err(|e| format!("Failed to delete {}: {e}", full_path.display())) + } + + async fn file_exists(&self, path: &str) -> Result { + let full_path = self.resolve_path(path); + Ok(full_path.exists()) + } + + async fn list_directory( + &self, + path: &str, + depth: Option, + ) -> Result, String> { + let full_path = self.resolve_path(path); + let max_depth = depth.unwrap_or(1); + + fn list_recursive( + base: &std::path::Path, + prefix: &str, + current_depth: usize, + max_depth: usize, + entries: &mut Vec, + ) -> Result<(), String> { + let mut dir_entries: Vec = std::fs::read_dir(base) + .map_err(|e| format!("Failed to read directory {}: {e}", base.display()))? + .filter_map(std::result::Result::ok) + .collect(); + dir_entries.sort_by_key(std::fs::DirEntry::file_name); + + for entry in dir_entries { + let metadata = entry + .metadata() + .map_err(|e| format!("Failed to read metadata: {e}"))?; + let name = if prefix.is_empty() { + entry.file_name().to_string_lossy().into_owned() + } else { + format!("{prefix}/{}", entry.file_name().to_string_lossy()) + }; + let is_dir = metadata.is_dir(); + entries.push(DirEntry { + name: name.clone(), + is_dir, + size: if metadata.is_file() { + Some(metadata.len()) + } else { + None + }, + }); + if is_dir && current_depth + 1 < max_depth { + list_recursive(&entry.path(), &name, current_depth + 1, max_depth, entries)?; + } + } + Ok(()) + } + + let mut entries = Vec::new(); + list_recursive(&full_path, "", 0, max_depth, &mut entries)?; + Ok(entries) + } + + async fn exec_command( + &self, + command: &str, + timeout_ms: u64, + working_dir: Option<&str>, + env_vars: Option<&std::collections::HashMap>, + cancel_token: Option, + ) -> Result { + let start = Instant::now(); + + let mut filtered_env: Vec<(String, String)> = std::env::vars() + .filter(|(key, _)| !Self::should_filter_env_var(key)) + .collect(); + + if let Some(extra) = env_vars { + for (k, v) in extra { + filtered_env.push((k.clone(), v.clone())); + } + } + + let effective_dir = + working_dir.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from); + + let mut cmd = Command::new("/bin/bash"); + cmd.arg("-c") + .arg(command) + .current_dir(&effective_dir) + .env_clear() + .envs(filtered_env) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + libc::setpgid(0, 0); + Ok(()) + }); + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn command: {e}"))?; + + let timeout_duration = std::time::Duration::from_millis(timeout_ms); + let token = cancel_token.unwrap_or_default(); + + // Take stdout/stderr handles before entering the select! so we can + // drain them concurrently. Without this, the child can deadlock: if + // it writes more than the OS pipe buffer (~64 KB) the write() syscall + // blocks until the parent drains the pipe, but the parent is blocked + // on child.wait(). + let mut stdout_pipe = child.stdout.take(); + let mut stderr_pipe = child.stderr.take(); + let stdout_task = tokio::spawn(async move { + let mut buf = String::new(); + if let Some(ref mut r) = stdout_pipe { + let _ = r.read_to_string(&mut buf).await; + } + buf + }); + let stderr_task = tokio::spawn(async move { + let mut buf = String::new(); + if let Some(ref mut r) = stderr_pipe { + let _ = r.read_to_string(&mut buf).await; + } + buf + }); + + let (timed_out, exit_code) = tokio::select! { + status_result = child.wait() => { + let status = status_result.map_err(|e| format!("Failed to wait for process: {e}"))?; + (false, status.code().unwrap_or(-1)) + } + () = tokio::time::sleep(timeout_duration) => { + sigterm_then_kill(&mut child).await; + (true, -1) + } + () = token.cancelled() => { + sigterm_then_kill(&mut child).await; + (true, -1) + } + }; + + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + + let stdout_str = stdout_task.await.unwrap_or_default(); + let stderr_str = stderr_task.await.unwrap_or_default(); + + Ok(ExecResult { + stdout: stdout_str, + stderr: stderr_str, + exit_code, + timed_out, + duration_ms, + }) + } + + async fn grep( + &self, + pattern: &str, + path: &str, + options: &GrepOptions, + ) -> Result, String> { + let full_path = self.resolve_path(path); + + // Try rg (ripgrep) first, fall back to grep + let use_rg = *self.rg_available.get_or_init(|| { + std::process::Command::new("rg") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + }); + + let output = if use_rg { + let mut args = vec!["-n".to_string()]; + if options.case_insensitive { + args.push("-i".into()); + } + if let Some(ref glob_filter) = options.glob_filter { + args.push("--glob".into()); + args.push(glob_filter.clone()); + } + if let Some(max) = options.max_results { + args.push("-m".into()); + args.push(max.to_string()); + } + args.push(pattern.into()); + args.push(full_path.to_string_lossy().into_owned()); + + std::process::Command::new("rg") + .args(&args) + .output() + .map_err(|e| format!("Failed to run rg: {e}"))? + } else { + let mut args = vec!["-rn".to_string()]; + if options.case_insensitive { + args.push("-i".into()); + } + if let Some(ref glob_filter) = options.glob_filter { + args.push("--include".into()); + args.push(glob_filter.clone()); + } + if let Some(max) = options.max_results { + args.push("-m".into()); + args.push(max.to_string()); + } + args.push(pattern.into()); + args.push(full_path.to_string_lossy().into_owned()); + + std::process::Command::new("grep") + .args(&args) + .output() + .map_err(|e| format!("Failed to run grep: {e}"))? + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let results: Vec = stdout + .lines() + .map(String::from) + .filter(|l| !l.is_empty()) + .collect(); + Ok(results) + } + + async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { + let base_dir = + path.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from); + + let full_pattern = if Path::new(pattern).is_absolute() { + pattern.to_string() + } else { + format!("{}/{pattern}", base_dir.display()) + }; + + let mut results: Vec = glob::glob(&full_pattern) + .map_err(|e| format!("Invalid glob pattern: {e}"))? + .filter_map(Result::ok) + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + // Sort by mtime (newest first) + results.sort_by(|a, b| { + let mtime_a = std::fs::metadata(a) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let mtime_b = std::fs::metadata(b) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + mtime_b.cmp(&mtime_a) + }); + + Ok(results) + } + + async fn download_file_to_local( + &self, + remote_path: &str, + local_path: &Path, + ) -> Result<(), String> { + let full_path = self.resolve_path(remote_path); + 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::copy(&full_path, local_path).await.map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + full_path.display(), + local_path.display() + ) + })?; + Ok(()) + } + + async fn upload_file_from_local( + &self, + local_path: &Path, + remote_path: &str, + ) -> Result<(), String> { + let full_path = self.resolve_path(remote_path); + if let Some(parent) = full_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create parent dirs: {e}"))?; + } + tokio::fs::copy(local_path, &full_path).await.map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + local_path.display(), + full_path.display() + ) + })?; + Ok(()) + } + + async fn initialize(&self) -> Result<(), String> { + self.emit(SandboxEvent::Initializing { + provider: "local".into(), + }); + let start = Instant::now(); + let result = tokio::fs::create_dir_all(&self.working_directory) + .await + .map_err(|e| format!("Failed to create working directory: {e}")); + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + match &result { + Ok(()) => self.emit(SandboxEvent::Ready { + provider: "local".into(), + duration_ms, + name: None, + cpu: None, + memory: None, + url: None, + }), + Err(e) => self.emit(SandboxEvent::InitializeFailed { + provider: "local".into(), + error: e.clone(), + duration_ms, + }), + } + result + } + + async fn cleanup(&self) -> Result<(), String> { + self.emit(SandboxEvent::CleanupStarted { + provider: "local".into(), + }); + let start = Instant::now(); + let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::CleanupCompleted { + provider: "local".into(), + duration_ms, + }); + Ok(()) + } + + fn working_directory(&self) -> &str { + self.working_directory.to_str().unwrap_or(".") + } + + fn platform(&self) -> &str { + if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "unknown" + } + } + + fn os_version(&self) -> String { + #[cfg(unix)] + { + let output = std::process::Command::new("uname").arg("-r").output(); + match output { + Ok(out) => { + let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); + format!("{} {version}", self.platform()) + } + Err(_) => self.platform().to_string(), + } + } + #[cfg(not(unix))] + { + self.platform().to_string() + } + } +} + +/// Send SIGTERM to the process group, wait 2s for graceful shutdown, then SIGKILL. +async fn sigterm_then_kill(child: &mut tokio::process::Child) { + #[cfg(unix)] + if let Some(pid) = child.id() { + unsafe { + libc::kill(-(pid as i32), libc::SIGTERM); + } + if tokio::time::timeout(std::time::Duration::from_secs(2), child.wait()) + .await + .is_err() + { + let _ = child.kill().await; + let _ = child.wait().await; + } + } else { + let _ = child.kill().await; + let _ = child.wait().await; + } + #[cfg(not(unix))] + { + let _ = child.kill().await; + let _ = child.wait().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn temp_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("local_env_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn read_file_with_line_numbers() { + let dir = temp_dir(); + std::fs::write(dir.join("test.txt"), "hello\nworld\nfoo").unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let result = env.read_file("test.txt", None, None).await.unwrap(); + + assert_eq!(result, "1 | hello\n2 | world\n3 | foo\n"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn read_file_line_number_padding() { + let dir = temp_dir(); + let content: String = (1..=12).map(|i| format!("line {i}\n")).collect(); + std::fs::write(dir.join("padded.txt"), content.trim_end()).unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let result = env.read_file("padded.txt", None, None).await.unwrap(); + + assert!(result.starts_with(" 1 | line 1\n")); + assert!(result.contains("12 | line 12\n")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn read_file_not_found() { + let dir = temp_dir(); + let env = LocalSandbox::new(dir.clone()); + let result = env.read_file("nonexistent.txt", None, None).await; + assert!(result.is_err()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_file_creates_parent_dirs() { + let dir = temp_dir(); + let env = LocalSandbox::new(dir.clone()); + env.write_file("sub/dir/test.txt", "content").await.unwrap(); + + let written = std::fs::read_to_string(dir.join("sub/dir/test.txt")).unwrap(); + assert_eq!(written, "content"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn file_exists_true() { + let dir = temp_dir(); + std::fs::write(dir.join("exists.txt"), "data").unwrap(); + + let env = LocalSandbox::new(dir.clone()); + assert!(env.file_exists("exists.txt").await.unwrap()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn file_exists_false() { + let dir = temp_dir(); + let env = LocalSandbox::new(dir.clone()); + assert!(!env.file_exists("nope.txt").await.unwrap()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn list_directory_sorted() { + let dir = temp_dir(); + std::fs::write(dir.join("b.txt"), "b").unwrap(); + std::fs::write(dir.join("a.txt"), "a").unwrap(); + std::fs::create_dir(dir.join("c_dir")).unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let entries = env.list_directory(".", None).await.unwrap(); + + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].name, "a.txt"); + assert!(!entries[0].is_dir); + assert!(entries[0].size.is_some()); + assert_eq!(entries[1].name, "b.txt"); + assert_eq!(entries[2].name, "c_dir"); + assert!(entries[2].is_dir); + assert!(entries[2].size.is_none()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn exec_command_echo() { + let dir = temp_dir(); + let env = LocalSandbox::new(dir.clone()); + let result = env + .exec_command("echo hello", 5000, None, None, None) + .await + .unwrap(); + + assert_eq!(result.stdout.trim(), "hello"); + assert_eq!(result.exit_code, 0); + assert!(!result.timed_out); + assert!(result.duration_ms < 5000); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn exec_command_exit_code() { + let dir = temp_dir(); + let env = LocalSandbox::new(dir.clone()); + let result = env + .exec_command("exit 42", 5000, None, None, None) + .await + .unwrap(); + + assert_eq!(result.exit_code, 42); + assert!(!result.timed_out); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn exec_command_timeout() { + let dir = temp_dir(); + let env = LocalSandbox::new(dir.clone()); + let result = env + .exec_command("sleep 10", 200, None, None, None) + .await + .unwrap(); + + assert!(result.timed_out); + assert_eq!(result.exit_code, -1); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn exec_command_stderr() { + let dir = temp_dir(); + let env = LocalSandbox::new(dir.clone()); + let result = env + .exec_command("echo err >&2", 5000, None, None, None) + .await + .unwrap(); + + assert_eq!(result.stderr.trim(), "err"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn env_var_filtering() { + assert!(LocalSandbox::should_filter_env_var("OPENAI_API_KEY")); + assert!(LocalSandbox::should_filter_env_var("ANTHROPIC_API_KEY")); + assert!(LocalSandbox::should_filter_env_var("DB_PASSWORD")); + assert!(LocalSandbox::should_filter_env_var("AWS_SECRET")); + assert!(LocalSandbox::should_filter_env_var("AUTH_TOKEN")); + assert!(LocalSandbox::should_filter_env_var("MY_CREDENTIAL")); + // Case insensitive + assert!(LocalSandbox::should_filter_env_var("my_api_key")); + assert!(LocalSandbox::should_filter_env_var("Some_Secret")); + // Should not filter + assert!(!LocalSandbox::should_filter_env_var("PATH")); + assert!(!LocalSandbox::should_filter_env_var("HOME")); + assert!(!LocalSandbox::should_filter_env_var("EDITOR")); + assert!(!LocalSandbox::should_filter_env_var("SECRET_PATH")); + } + + #[test] + fn platform_is_known() { + let env = LocalSandbox::new(PathBuf::from("/tmp")); + let platform = env.platform(); + assert!( + platform == "darwin" || platform == "linux" || platform == "windows", + "Unknown platform: {platform}" + ); + } + + #[test] + fn os_version_contains_platform() { + let env = LocalSandbox::new(PathBuf::from("/tmp")); + let version = env.os_version(); + assert!( + version.contains(env.platform()), + "OS version should contain platform: {version}" + ); + } + + #[test] + fn working_directory_accessor() { + let env = LocalSandbox::new(PathBuf::from("/tmp/test_dir")); + assert_eq!(env.working_directory(), "/tmp/test_dir"); + } + + #[tokio::test] + async fn initialize_creates_directory() { + let dir = std::env::temp_dir().join(format!("init_test_{}", uuid::Uuid::new_v4())); + let env = LocalSandbox::new(dir.clone()); + env.initialize().await.unwrap(); + assert!(dir.exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn initialize_emits_events() { + use crate::SandboxEvent; + use std::sync::{Arc, Mutex}; + + let dir = std::env::temp_dir().join(format!("init_event_test_{}", uuid::Uuid::new_v4())); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let events_clone = Arc::clone(&events); + + let mut env = LocalSandbox::new(dir.clone()); + env.set_event_callback(Arc::new(move |e| { + events_clone.lock().unwrap().push(e); + })); + + env.initialize().await.unwrap(); + + let captured = events.lock().unwrap(); + assert_eq!(captured.len(), 2); + assert!( + matches!(&captured[0], SandboxEvent::Initializing { provider } if provider == "local") + ); + assert!( + matches!(&captured[1], SandboxEvent::Ready { provider, .. } if provider == "local") + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn cleanup_emits_events() { + use crate::SandboxEvent; + use std::sync::{Arc, Mutex}; + + let dir = temp_dir(); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let events_clone = Arc::clone(&events); + + let mut env = LocalSandbox::new(dir.clone()); + env.set_event_callback(Arc::new(move |e| { + events_clone.lock().unwrap().push(e); + })); + + env.cleanup().await.unwrap(); + + let captured = events.lock().unwrap(); + assert_eq!(captured.len(), 2); + assert!( + matches!(&captured[0], SandboxEvent::CleanupStarted { provider } if provider == "local") + ); + assert!( + matches!(&captured[1], SandboxEvent::CleanupCompleted { provider, .. } if provider == "local") + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn grep_finds_matches() { + let dir = temp_dir(); + std::fs::write( + dir.join("test.rs"), + "fn main() {\n println!(\"hello\");\n}\n", + ) + .unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let results = env + .grep("println", "test.rs", &GrepOptions::default()) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert!(results[0].contains("println")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn grep_case_insensitive() { + let dir = temp_dir(); + std::fs::write(dir.join("test.txt"), "Hello\nhello\nHELLO\n").unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let results = env + .grep( + "hello", + "test.txt", + &GrepOptions { + case_insensitive: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 3); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn grep_max_results() { + let dir = temp_dir(); + std::fs::write(dir.join("test.txt"), "match1\nmatch2\nmatch3\nmatch4\n").unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let results = env + .grep( + "match", + "test.txt", + &GrepOptions { + max_results: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn glob_finds_files() { + let dir = temp_dir(); + std::fs::write(dir.join("a.rs"), "").unwrap(); + std::fs::write(dir.join("b.rs"), "").unwrap(); + std::fs::write(dir.join("c.txt"), "").unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let results = env.glob("*.rs", None).await.unwrap(); + + assert_eq!(results.len(), 2); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn local_sandbox_download_file_to_local() { + let dir = temp_dir(); + std::fs::write(dir.join("source.txt"), "hello download").unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let dest = dir.join("output/downloaded.txt"); + env.download_file_to_local("source.txt", &dest) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello download"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn local_sandbox_download_file_to_local_creates_parent_dirs() { + let dir = temp_dir(); + std::fs::write(dir.join("data.bin"), "binary-ish").unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let dest = dir.join("deep/nested/dir/data.bin"); + env.download_file_to_local("data.bin", &dest).await.unwrap(); + + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary-ish"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn local_sandbox_download_file_to_local_binary() { + let dir = temp_dir(); + let binary_data: Vec = (0u8..=255).collect(); + std::fs::write(dir.join("binary.bin"), &binary_data).unwrap(); + + let env = LocalSandbox::new(dir.clone()); + let dest = dir.join("out/binary.bin"); + env.download_file_to_local("binary.bin", &dest) + .await + .unwrap(); + + assert_eq!(std::fs::read(&dest).unwrap(), binary_data); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/lib/crates/fabro-sandbox/src/read_guard.rs b/lib/crates/fabro-sandbox/src/read_guard.rs new file mode 100644 index 000000000..04ea61afb --- /dev/null +++ b/lib/crates/fabro-sandbox/src/read_guard.rs @@ -0,0 +1,272 @@ +use crate::*; +use std::collections::HashSet; +use std::path::{Component, PathBuf}; +use std::sync::{Arc, Mutex}; +use tracing::{debug, warn}; + +/// Decorator that prevents writing to files the agent hasn't read first. +/// +/// Tracks which file paths the agent has seen (via `mark_agent_read`, called by +/// tool executors after agent-visible reads) and returns an error when `write_file` +/// or `delete_file` targets an existing file that hasn't been read. +/// Writing to new (non-existent) files is always allowed. +pub struct ReadBeforeWriteSandbox { + inner: Arc, + read_set: Mutex>, +} + +impl ReadBeforeWriteSandbox { + pub fn new(inner: Arc) -> Self { + Self { + inner, + read_set: Mutex::new(HashSet::new()), + } + } + + fn normalize_path(&self, path: &str) -> String { + let full = if path.starts_with('/') { + PathBuf::from(path) + } else { + PathBuf::from(self.inner.working_directory()).join(path) + }; + + let mut parts: Vec = Vec::new(); + for component in full.components() { + match component { + Component::Normal(s) => parts.push(s.to_string_lossy().into_owned()), + Component::ParentDir => { + parts.pop(); + } + Component::RootDir | Component::CurDir | Component::Prefix(_) => {} + } + } + + format!("/{}", parts.join("/")) + } + + fn mark_read(&self, path: &str) { + let normalized = self.normalize_path(path); + self.read_set + .lock() + .expect("read_set lock poisoned") + .insert(normalized); + } + + fn has_read(&self, path: &str) -> bool { + let normalized = self.normalize_path(path); + self.read_set + .lock() + .expect("read_set lock poisoned") + .contains(&normalized) + } + + async fn guard_write(&self, path: &str) -> Result<(), String> { + let normalized = self.normalize_path(path); + if normalized.starts_with("/tmp/") { + return Ok(()); + } + let exists = self.inner.file_exists(path).await?; + if exists && !self.has_read(path) { + warn!(path = %path, "Write blocked: file not read by agent"); + Err(format!( + "Cannot write to '{path}': file exists but has not been read. \ + Use read_file to read the file before writing to it." + )) + } else { + Ok(()) + } + } +} + +crate::delegate_sandbox! { + ReadBeforeWriteSandbox => inner { + async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { + self.guard_write(path).await?; + self.inner.write_file(path, content).await + } + + async fn delete_file(&self, path: &str) -> Result<(), String> { + self.guard_write(path).await?; + self.inner.delete_file(path).await + } + + fn mark_agent_read(&self, path: &str) { + debug!(path = %path, "File marked as agent-read"); + self.mark_read(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::MockSandbox; + use std::collections::HashMap; + + fn mock_with_files(files: HashMap) -> MockSandbox { + MockSandbox { + files, + working_dir: "/work", + ..Default::default() + } + } + + // Cycle 1: write to existing unread file → error + #[tokio::test] + async fn write_to_existing_unread_file_returns_error() { + let mock = mock_with_files(HashMap::from([("a.ts".into(), "content".into())])); + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + let result = env.write_file("a.ts", "new content").await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("a.ts")); + assert!(err.contains("read")); + } + + // Cycle 2: write to non-existent file → success + #[tokio::test] + async fn write_to_nonexistent_file_succeeds() { + let mock = mock_with_files(HashMap::new()); + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + let result = env.write_file("new.ts", "content").await; + + assert!(result.is_ok()); + } + + // Cycle 3: mark_agent_read then write → success + #[tokio::test] + async fn read_then_write_succeeds() { + let mock = mock_with_files(HashMap::from([("a.ts".into(), "content".into())])); + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + env.mark_agent_read("a.ts"); + let result = env.write_file("a.ts", "new content").await; + + assert!(result.is_ok()); + } + + // Cycle 4: read_file alone does NOT satisfy guard + #[tokio::test] + async fn read_file_alone_does_not_satisfy_guard() { + let mock = mock_with_files(HashMap::from([("a.ts".into(), "content".into())])); + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + env.read_file("a.ts", None, None).await.unwrap(); + let result = env.write_file("a.ts", "new content").await; + + assert!(result.is_err()); + } + + // Cycle 5: grep alone does NOT populate read set + #[tokio::test] + async fn grep_does_not_populate_read_set() { + let mock = MockSandbox { + files: HashMap::from([("b.ts".into(), "content".into())]), + grep_results: vec!["b.ts:1:content".into()], + working_dir: "/work", + ..Default::default() + }; + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + env.grep("pattern", ".", &GrepOptions::default()) + .await + .unwrap(); + let result = env.write_file("b.ts", "new").await; + + assert!(result.is_err()); + } + + // Cycle 6: mark_agent_read from grep results then write → success + #[tokio::test] + async fn mark_agent_read_from_grep_then_write_succeeds() { + let mock = MockSandbox { + files: HashMap::from([("b.ts".into(), "content".into())]), + grep_results: vec!["b.ts:1:content".into()], + working_dir: "/work", + ..Default::default() + }; + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + env.mark_agent_read("b.ts"); + let result = env.write_file("b.ts", "new").await; + + assert!(result.is_ok()); + } + + // Cycle 7: glob does NOT populate read set + #[tokio::test] + async fn glob_does_not_populate_read_set() { + let mock = MockSandbox { + files: HashMap::from([("c.ts".into(), "content".into())]), + glob_results: vec!["c.ts".into()], + working_dir: "/work", + ..Default::default() + }; + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + env.glob("*.ts", None).await.unwrap(); + let result = env.write_file("c.ts", "new").await; + + assert!(result.is_err()); + } + + // Cycle 8: path normalization — relative vs absolute via mark_agent_read + #[tokio::test] + async fn path_normalization_relative_and_absolute() { + let mock = MockSandbox { + files: HashMap::from([ + ("a.ts".into(), "content".into()), + ("/work/a.ts".into(), "content".into()), + ]), + working_dir: "/work", + ..Default::default() + }; + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + env.mark_agent_read("a.ts"); + let result = env.write_file("/work/a.ts", "new content").await; + + assert!(result.is_ok()); + } + + // Cycle 9: delete unread file → error + #[tokio::test] + async fn delete_unread_file_returns_error() { + let mock = mock_with_files(HashMap::from([("d.ts".into(), "content".into())])); + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + let result = env.delete_file("d.ts").await; + + assert!(result.is_err()); + } + + // Cycle 10: error message is actionable + #[tokio::test] + async fn error_message_is_actionable() { + let mock = mock_with_files(HashMap::from([("main.rs".into(), "fn main() {}".into())])); + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + let err = env.write_file("main.rs", "new").await.unwrap_err(); + + assert!(err.contains("main.rs")); + assert!(err.contains("read_file")); + } + + // Cycle 11: write to /tmp bypasses guard + #[tokio::test] + async fn write_to_tmp_bypasses_guard() { + let mock = MockSandbox { + files: HashMap::from([("/tmp/fabro-commit-msg".into(), "old".into())]), + working_dir: "/work", + ..Default::default() + }; + let env = ReadBeforeWriteSandbox::new(Arc::new(mock)); + + let result = env.write_file("/tmp/fabro-commit-msg", "new").await; + + assert!(result.is_ok()); + } +} diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs new file mode 100644 index 000000000..3c31584ac --- /dev/null +++ b/lib/crates/fabro-sandbox/src/sandbox.rs @@ -0,0 +1,583 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt::Write; +use std::path::Path; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +/// Generates an `#[async_trait] impl Sandbox` block for a decorator type +/// that wraps an `Arc`. The caller provides custom method +/// implementations; all remaining trait methods delegate to the inner field. +/// +/// # Usage +/// +/// ```ignore +/// delegate_sandbox! { +/// MyDecorator => inner { +/// // Only provide methods with custom logic — the rest delegate automatically. +/// async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result { +/// // custom logic... +/// } +/// } +/// } +/// ``` +#[macro_export] +macro_rules! delegate_sandbox { + ( + $type:ty => $field:ident { + $($custom:item)* + } + ) => { + #[async_trait::async_trait] + impl $crate::Sandbox for $type { + $($custom)* + + async fn file_exists(&self, path: &str) -> Result { + self.$field.file_exists(path).await + } + + async fn list_directory( + &self, + path: &str, + depth: Option, + ) -> Result, String> { + self.$field.list_directory(path, depth).await + } + + async fn exec_command( + &self, + command: &str, + timeout_ms: u64, + working_dir: Option<&str>, + env_vars: Option<&std::collections::HashMap>, + cancel_token: Option, + ) -> Result<$crate::ExecResult, String> { + self.$field + .exec_command(command, timeout_ms, working_dir, env_vars, cancel_token) + .await + } + + async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { + self.$field.glob(pattern, path).await + } + + async fn download_file_to_local( + &self, + remote_path: &str, + local_path: &std::path::Path, + ) -> Result<(), String> { + self.$field.download_file_to_local(remote_path, local_path).await + } + + async fn upload_file_from_local( + &self, + local_path: &std::path::Path, + remote_path: &str, + ) -> Result<(), String> { + self.$field.upload_file_from_local(local_path, remote_path).await + } + + async fn initialize(&self) -> Result<(), String> { + self.$field.initialize().await + } + + async fn cleanup(&self) -> Result<(), String> { + self.$field.cleanup().await + } + + fn working_directory(&self) -> &str { + self.$field.working_directory() + } + + fn platform(&self) -> &str { + self.$field.platform() + } + + fn os_version(&self) -> String { + self.$field.os_version() + } + + fn sandbox_info(&self) -> String { + self.$field.sandbox_info() + } + + async fn refresh_push_credentials(&self) -> Result<(), String> { + self.$field.refresh_push_credentials().await + } + + async fn set_autostop_interval(&self, minutes: i32) -> Result<(), String> { + self.$field.set_autostop_interval(minutes).await + } + + fn is_remote(&self) -> bool { + self.$field.is_remote() + } + + async fn ssh_access_command(&self) -> Result, String> { + self.$field.ssh_access_command().await + } + + fn origin_url(&self) -> Option<&str> { + self.$field.origin_url() + } + + async fn get_preview_url(&self, port: u16) -> Result)>, String> { + self.$field.get_preview_url(port).await + } + + async fn read_file( + &self, + path: &str, + offset: Option, + limit: Option, + ) -> Result { + self.$field.read_file(path, offset, limit).await + } + + async fn grep( + &self, + pattern: &str, + path: &str, + options: &$crate::GrepOptions, + ) -> Result, String> { + self.$field.grep(pattern, path, options).await + } + } + }; +} + +/// Events emitted during sandbox lifecycle operations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SandboxEvent { + // -- Common lifecycle -- + Initializing { + provider: String, + }, + Ready { + provider: String, + duration_ms: u64, + name: Option, + cpu: Option, + memory: Option, + url: Option, + }, + InitializeFailed { + provider: String, + error: String, + duration_ms: u64, + }, + CleanupStarted { + provider: String, + }, + CleanupCompleted { + provider: String, + duration_ms: u64, + }, + CleanupFailed { + provider: String, + error: String, + }, + + // -- Docker -- + SnapshotPulling { + name: String, + }, + SnapshotPulled { + name: String, + duration_ms: u64, + }, + + // -- Daytona snapshots -- + SnapshotEnsuring { + name: String, + }, + SnapshotCreating { + name: String, + }, + SnapshotReady { + name: String, + duration_ms: u64, + }, + SnapshotFailed { + name: String, + error: String, + }, + + // -- Daytona git -- + GitCloneStarted { + url: String, + branch: Option, + }, + GitCloneCompleted { + url: String, + duration_ms: u64, + }, + GitCloneFailed { + url: String, + error: String, + }, +} + +impl SandboxEvent { + pub fn trace(&self) { + use tracing::{debug, error, info, warn}; + match self { + Self::Initializing { provider } => { + debug!(provider, "Sandbox initializing"); + } + Self::Ready { + provider, + duration_ms, + .. + } => { + info!(provider, duration_ms, "Sandbox ready"); + } + Self::InitializeFailed { + provider, + error, + duration_ms, + } => { + error!(provider, error, duration_ms, "Sandbox init failed"); + } + Self::CleanupStarted { provider } => { + debug!(provider, "Sandbox cleanup started"); + } + Self::CleanupCompleted { + provider, + duration_ms, + } => { + debug!(provider, duration_ms, "Sandbox cleanup completed"); + } + Self::CleanupFailed { provider, error } => { + warn!(provider, error, "Sandbox cleanup failed"); + } + Self::SnapshotPulling { name } => { + debug!(name, "Snapshot pulling"); + } + Self::SnapshotPulled { name, duration_ms } => { + debug!(name, duration_ms, "Snapshot pulled"); + } + Self::SnapshotEnsuring { name } => { + debug!(name, "Snapshot ensuring"); + } + Self::SnapshotCreating { name } => { + debug!(name, "Snapshot creating"); + } + Self::SnapshotReady { name, duration_ms } => { + info!(name, duration_ms, "Snapshot ready"); + } + Self::SnapshotFailed { name, error } => { + error!(name, error, "Snapshot failed"); + } + Self::GitCloneStarted { url, branch } => { + debug!( + url, + branch = branch.as_deref().unwrap_or(""), + "Git clone started" + ); + } + Self::GitCloneCompleted { url, duration_ms } => { + debug!(url, duration_ms, "Git clone completed"); + } + Self::GitCloneFailed { url, error } => { + error!(url, error, "Git clone failed"); + } + } + } +} + +/// Callback type for sandbox events. +pub type SandboxEventCallback = Arc; + +/// Formats file content with line numbers for display. +/// +/// Applies optional offset (0-based lines to skip) and limit (max lines to return). +/// Line numbers are 1-based and right-aligned. +#[must_use] +pub fn format_lines_numbered(content: &str, offset: Option, limit: Option) -> String { + let all_lines: Vec<&str> = content.lines().collect(); + let skip = offset.unwrap_or(0); + let take = limit.unwrap_or(all_lines.len()); + let selected: Vec<&str> = all_lines.into_iter().skip(skip).take(take).collect(); + let width = (skip + selected.len()).to_string().len().max(1); + let mut result = String::new(); + for (i, line) in selected.iter().enumerate() { + let line_num = skip + i + 1; + let _ = writeln!(result, "{line_num:>width$} | {line}"); + } + result +} + +#[derive(Debug, Clone)] +pub struct ExecResult { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, + pub timed_out: bool, + pub duration_ms: u64, +} + +#[derive(Debug, Clone)] +pub struct DirEntry { + pub name: String, + pub is_dir: bool, + pub size: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct GrepOptions { + pub glob_filter: Option, + pub case_insensitive: bool, + pub max_results: Option, +} + +#[async_trait] +pub trait Sandbox: Send + Sync { + async fn read_file( + &self, + path: &str, + offset: Option, + limit: Option, + ) -> Result; + async fn write_file(&self, path: &str, content: &str) -> Result<(), String>; + async fn delete_file(&self, path: &str) -> Result<(), String>; + async fn file_exists(&self, path: &str) -> Result; + async fn list_directory( + &self, + path: &str, + depth: Option, + ) -> Result, String>; + async fn exec_command( + &self, + command: &str, + timeout_ms: u64, + working_dir: Option<&str>, + env_vars: Option<&std::collections::HashMap>, + cancel_token: Option, + ) -> Result; + async fn grep( + &self, + pattern: &str, + path: &str, + options: &GrepOptions, + ) -> Result, String>; + async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String>; + /// Copy a file from the sandbox to a local filesystem path. + /// Handles binary files correctly across all sandbox types. + async fn download_file_to_local( + &self, + remote_path: &str, + local_path: &Path, + ) -> Result<(), String>; + /// Copy a file from the local filesystem into the sandbox. + /// Handles binary files correctly across all sandbox types. + async fn upload_file_from_local( + &self, + local_path: &Path, + remote_path: &str, + ) -> Result<(), String>; + async fn initialize(&self) -> Result<(), String>; + async fn cleanup(&self) -> Result<(), String>; + fn working_directory(&self) -> &str; + fn platform(&self) -> &str; + fn os_version(&self) -> String; + /// Return a human-readable identifier for the sandbox (e.g. container ID, sandbox name). + /// Used when `--preserve-sandbox` is active to tell the user how to reconnect. + fn sandbox_info(&self) -> String { + String::new() + } + + /// Refresh git push credentials (e.g. rotate an expiring GitHub App token). + /// Default is a no-op; Daytona overrides to update the remote URL with a fresh token. + async fn refresh_push_credentials(&self) -> Result<(), String> { + Ok(()) + } + + /// Set the auto-stop interval in minutes (0 to disable). + /// Default is a no-op; Daytona overrides to call the Daytona API. + async fn set_autostop_interval(&self, _minutes: i32) -> Result<(), String> { + Ok(()) + } + + /// Whether this sandbox runs on a remote machine (e.g. Daytona, exe.dev). + fn is_remote(&self) -> bool { + false + } + + /// Return an SSH command string for connecting to this sandbox, if supported. + async fn ssh_access_command(&self) -> Result, String> { + Ok(None) + } + + /// The display URL of the cloned origin remote, if known. + fn origin_url(&self) -> Option<&str> { + None + } + + /// Get an authenticated preview URL for a port exposed by this sandbox. + /// Returns `Ok(None)` when the sandbox does not support port previews. + /// Used to connect to services (e.g. MCP servers) running inside the sandbox. + async fn get_preview_url( + &self, + _port: u16, + ) -> Result)>, String> { + Ok(None) + } + + /// Record that the agent has explicitly read (seen) the given file path. + /// Called by tool executors after agent-visible reads (e.g. `read_file`, `grep`). + /// Default is a no-op; `ReadBeforeWriteSandbox` overrides to populate its read set. + fn mark_agent_read(&self, _path: &str) {} +} + +/// Shell-quote a string using `shlex::try_quote`, with a fallback for edge cases. +pub fn shell_quote(s: &str) -> String { + shlex::try_quote(s).map_or_else( + |_| format!("'{}'", s.replace('\'', "'\\''")), + |q| q.to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exec_result_fields() { + let result = ExecResult { + stdout: "out".into(), + stderr: "err".into(), + exit_code: 1, + timed_out: true, + duration_ms: 5000, + }; + assert_eq!(result.exit_code, 1); + assert!(result.timed_out); + assert_eq!(result.duration_ms, 5000); + } + + #[test] + fn dir_entry_fields() { + let entry = DirEntry { + name: "src".into(), + is_dir: true, + size: None, + }; + assert_eq!(entry.name, "src"); + assert!(entry.is_dir); + assert!(entry.size.is_none()); + } + + #[test] + fn grep_options_defaults() { + let opts = GrepOptions::default(); + assert!(opts.glob_filter.is_none()); + assert!(!opts.case_insensitive); + assert!(opts.max_results.is_none()); + } + + #[test] + fn sandbox_event_serialization_round_trip() { + let events = vec![ + SandboxEvent::Initializing { + provider: "local".into(), + }, + SandboxEvent::Ready { + provider: "local".into(), + duration_ms: 50, + name: None, + cpu: None, + memory: None, + url: None, + }, + SandboxEvent::InitializeFailed { + provider: "docker".into(), + error: "no daemon".into(), + duration_ms: 100, + }, + SandboxEvent::CleanupStarted { + provider: "daytona".into(), + }, + SandboxEvent::CleanupCompleted { + provider: "daytona".into(), + duration_ms: 200, + }, + SandboxEvent::CleanupFailed { + provider: "docker".into(), + error: "container gone".into(), + }, + SandboxEvent::SnapshotPulling { + name: "ubuntu:22.04".into(), + }, + SandboxEvent::SnapshotPulled { + name: "ubuntu:22.04".into(), + duration_ms: 5000, + }, + SandboxEvent::SnapshotEnsuring { + name: "my-snap".into(), + }, + SandboxEvent::SnapshotCreating { + name: "my-snap".into(), + }, + SandboxEvent::SnapshotReady { + name: "my-snap".into(), + duration_ms: 30000, + }, + SandboxEvent::SnapshotFailed { + name: "my-snap".into(), + error: "build failed".into(), + }, + SandboxEvent::GitCloneStarted { + url: "https://github.com/org/repo.git".into(), + branch: Some("main".into()), + }, + SandboxEvent::GitCloneCompleted { + url: "https://github.com/org/repo.git".into(), + duration_ms: 8000, + }, + SandboxEvent::GitCloneFailed { + url: "https://github.com/org/repo.git".into(), + error: "auth failed".into(), + }, + ]; + + assert_eq!(events.len(), 15, "should test all 15 variants"); + + for event in &events { + let json = serde_json::to_string(event).unwrap(); + let deserialized: SandboxEvent = serde_json::from_str(&json).unwrap(); + let json2 = serde_json::to_string(&deserialized).unwrap(); + assert_eq!(json, json2); + } + } + + #[test] + fn sandbox_event_callback_type_compiles() { + let cb: SandboxEventCallback = Arc::new(|_event| {}); + cb(SandboxEvent::Initializing { + provider: "test".into(), + }); + } + + #[test] + fn format_lines_numbered_basic() { + let result = format_lines_numbered("hello\nworld\nfoo", None, None); + assert_eq!(result, "1 | hello\n2 | world\n3 | foo\n"); + } + + #[test] + fn format_lines_numbered_with_offset_limit() { + let result = format_lines_numbered("a\nb\nc\nd\ne", Some(1), Some(2)); + assert!(result.contains("2 | b")); + assert!(result.contains("3 | c")); + assert!(!result.contains("1 | a")); + assert!(!result.contains("4 | d")); + } + + #[test] + fn shell_quote_basic() { + assert_eq!(shell_quote("hello"), "hello"); + assert_eq!(shell_quote("hello world"), "'hello world'"); + } +} diff --git a/lib/crates/fabro-sprites/src/cli_runner.rs b/lib/crates/fabro-sandbox/src/sprites/cli_runner.rs similarity index 96% rename from lib/crates/fabro-sprites/src/cli_runner.rs rename to lib/crates/fabro-sandbox/src/sprites/cli_runner.rs index 0958a04df..1a4b0685b 100644 --- a/lib/crates/fabro-sprites/src/cli_runner.rs +++ b/lib/crates/fabro-sandbox/src/sprites/cli_runner.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; -use crate::{SpriteOutput, SpriteRunner}; +use super::{SpriteOutput, SpriteRunner}; /// Real implementation that invokes the `sprite` CLI binary. #[derive(Default)] diff --git a/lib/crates/fabro-sprites/src/lib.rs b/lib/crates/fabro-sandbox/src/sprites/mod.rs similarity index 99% rename from lib/crates/fabro-sprites/src/lib.rs rename to lib/crates/fabro-sandbox/src/sprites/mod.rs index 46129762e..55ea553da 100644 --- a/lib/crates/fabro-sprites/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/sprites/mod.rs @@ -4,26 +4,21 @@ use std::collections::HashMap; use std::path::Path; use std::time::Instant; -use async_trait::async_trait; -use fabro_agent::sandbox::{ +use crate::{ 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; +use crate::shell_quote; + const WORKING_DIRECTORY: &str = "/home/sprite"; const PROVIDER: &str = "sprites"; -fn shell_quote(s: &str) -> String { - shlex::try_quote(s).map_or_else( - |_| format!("'{}'", s.replace('\'', "'\\''")), - |q| q.to_string(), - ) -} - /// Output from a sprite CLI command execution. pub struct SpriteOutput { pub stdout: String, diff --git a/lib/crates/fabro-ssh/src/lib.rs b/lib/crates/fabro-sandbox/src/ssh/mod.rs similarity index 98% rename from lib/crates/fabro-ssh/src/lib.rs rename to lib/crates/fabro-sandbox/src/ssh/mod.rs index 98331b04a..4fb0a9618 100644 --- a/lib/crates/fabro-ssh/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/ssh/mod.rs @@ -4,25 +4,19 @@ use std::collections::HashMap; use std::path::Path; use std::time::Instant; -use async_trait::async_trait; -use base64::Engine; -use fabro_agent::sandbox::{ +use crate::shell_quote; +use crate::{ format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, }; +use async_trait::async_trait; +use base64::Engine; use tokio_util::sync::CancellationToken; pub use openssh_runner::OpensshRunner; const PROVIDER: &str = "ssh"; -pub(crate) fn shell_quote(s: &str) -> String { - shlex::try_quote(s).map_or_else( - |_| format!("'{}'", s.replace('\'', "'\\''")), - |q| q.to_string(), - ) -} - /// Output from an SSH command execution. pub struct SshOutput { pub stdout: Vec, @@ -59,7 +53,7 @@ pub struct GitCloneParams { /// Sandbox that runs all operations on a user-provided SSH host. /// -/// Unlike ExeSandbox, there is no VM lifecycle management — the host +/// Unlike ExeSandbox, there is no VM lifecycle management -- the host /// must already be running and accessible via SSH. pub struct SshSandbox { ssh: tokio::sync::OnceCell>, @@ -125,7 +119,7 @@ impl SshSandbox { self.ssh .get() .map(|b| b.as_ref()) - .ok_or_else(|| "SSH sandbox not initialized — call initialize() first".to_string()) + .ok_or_else(|| "SSH sandbox not initialized -- call initialize() first".to_string()) } /// Return the SSH command to connect to this host. @@ -1022,7 +1016,7 @@ mod tests { assert_eq!(result.exit_code, -1); } - /// SSH runner that never completes — blocks forever. + /// SSH runner that never completes -- blocks forever. struct HangingSshRunner; #[async_trait] diff --git a/lib/crates/fabro-ssh/src/openssh_runner.rs b/lib/crates/fabro-sandbox/src/ssh/openssh_runner.rs similarity index 98% rename from lib/crates/fabro-ssh/src/openssh_runner.rs rename to lib/crates/fabro-sandbox/src/ssh/openssh_runner.rs index fdf590077..beb460a99 100644 --- a/lib/crates/fabro-ssh/src/openssh_runner.rs +++ b/lib/crates/fabro-sandbox/src/ssh/openssh_runner.rs @@ -1,7 +1,8 @@ use async_trait::async_trait; use openssh::{KnownHosts, SessionBuilder}; -use crate::{shell_quote, SshOutput, SshRunner}; +use super::{SshOutput, SshRunner}; +use crate::shell_quote; /// Real SSH implementation using the `openssh` crate (multiplexed connections). pub struct OpensshRunner { diff --git a/lib/crates/fabro-sandbox/src/test_support.rs b/lib/crates/fabro-sandbox/src/test_support.rs new file mode 100644 index 000000000..e8b01b1e2 --- /dev/null +++ b/lib/crates/fabro-sandbox/src/test_support.rs @@ -0,0 +1,388 @@ +use crate::*; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Mutex; +use tokio_util::sync::CancellationToken; + +// --- MockSandbox --- + +pub struct MockSandbox { + pub files: HashMap, + pub exec_result: ExecResult, + pub grep_results: Vec, + pub glob_results: Vec, + pub working_dir: &'static str, + pub platform_str: &'static str, + pub os_version_str: String, + /// When true, `read_file` applies offset/limit by splitting on lines. + pub apply_read_offset_limit: bool, + /// Captures (path, content) pairs from `write_file` calls. + pub written_files: Mutex>, + /// Captures the `timeout_ms` argument from `exec_command` calls. + pub captured_timeout: Mutex>, + /// Captures the `command` argument from `exec_command` calls. + pub captured_command: Mutex>, + /// Captures the `env_vars` argument from `exec_command` calls. + pub captured_env_vars: Mutex>>, + pub event_callback: Option, +} + +impl MockSandbox { + pub fn linux() -> Self { + Self { + working_dir: "/home/test", + platform_str: "linux", + os_version_str: "Linux 6.1.0".into(), + ..Default::default() + } + } +} + +impl MockSandbox { + fn emit(&self, event: SandboxEvent) { + event.trace(); + if let Some(ref cb) = self.event_callback { + cb(event); + } + } +} + +impl Default for MockSandbox { + fn default() -> Self { + Self { + files: HashMap::new(), + exec_result: ExecResult { + stdout: "mock output".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, + duration_ms: 10, + }, + grep_results: vec![], + glob_results: vec![], + working_dir: "/work", + platform_str: "darwin", + os_version_str: "Darwin 24.0.0".into(), + apply_read_offset_limit: false, + written_files: Mutex::new(Vec::new()), + captured_timeout: Mutex::new(None), + captured_command: Mutex::new(None), + captured_env_vars: Mutex::new(None), + event_callback: None, + } + } +} + +#[async_trait] +impl Sandbox for MockSandbox { + async fn read_file( + &self, + path: &str, + offset: Option, + limit: Option, + ) -> Result { + let content = self + .files + .get(path) + .cloned() + .ok_or_else(|| format!("File not found: {path}"))?; + + if self.apply_read_offset_limit { + let lines: Vec<&str> = content.lines().collect(); + let start = offset.unwrap_or(1).saturating_sub(1); + let count = limit.unwrap_or(2000); + let selected: Vec<&str> = lines.into_iter().skip(start).take(count).collect(); + Ok(selected.join("\n")) + } else { + Ok(content) + } + } + + async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { + self.written_files + .lock() + .expect("written_files lock poisoned") + .push((path.to_string(), content.to_string())); + Ok(()) + } + + async fn delete_file(&self, _path: &str) -> Result<(), String> { + Ok(()) + } + + async fn file_exists(&self, path: &str) -> Result { + Ok(self.files.contains_key(path)) + } + + async fn list_directory( + &self, + _path: &str, + _depth: Option, + ) -> Result, String> { + Ok(vec![]) + } + + async fn exec_command( + &self, + command: &str, + timeout_ms: u64, + _working_dir: Option<&str>, + env_vars: Option<&std::collections::HashMap>, + _cancel_token: Option, + ) -> Result { + *self + .captured_timeout + .lock() + .expect("captured_timeout lock poisoned") = Some(timeout_ms); + *self + .captured_command + .lock() + .expect("captured_command lock poisoned") = Some(command.to_string()); + *self + .captured_env_vars + .lock() + .expect("captured_env_vars lock poisoned") = env_vars.cloned(); + Ok(self.exec_result.clone()) + } + + async fn grep( + &self, + _pattern: &str, + _path: &str, + _options: &GrepOptions, + ) -> Result, String> { + Ok(self.grep_results.clone()) + } + + async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result, String> { + Ok(self.glob_results.clone()) + } + + async fn download_file_to_local( + &self, + remote_path: &str, + local_path: &std::path::Path, + ) -> Result<(), String> { + let content = self + .files + .get(remote_path) + .ok_or_else(|| format!("File not found: {remote_path}"))?; + 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, content.as_bytes()) + .await + .map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?; + Ok(()) + } + + async fn upload_file_from_local( + &self, + local_path: &std::path::Path, + _remote_path: &str, + ) -> Result<(), String> { + if !local_path.exists() { + return Err(format!("File not found: {}", local_path.display())); + } + Ok(()) + } + + async fn initialize(&self) -> Result<(), String> { + self.emit(SandboxEvent::Initializing { + provider: "mock".into(), + }); + self.emit(SandboxEvent::Ready { + provider: "mock".into(), + duration_ms: 0, + name: None, + cpu: None, + memory: None, + url: None, + }); + Ok(()) + } + + async fn cleanup(&self) -> Result<(), String> { + self.emit(SandboxEvent::CleanupStarted { + provider: "mock".into(), + }); + self.emit(SandboxEvent::CleanupCompleted { + provider: "mock".into(), + duration_ms: 0, + }); + Ok(()) + } + + fn working_directory(&self) -> &str { + self.working_dir + } + + fn platform(&self) -> &str { + self.platform_str + } + + fn os_version(&self) -> String { + self.os_version_str.clone() + } +} + +// --- MutableMockSandbox --- + +/// A mock sandbox with Mutex-protected files for tests that need +/// write operations to be visible to subsequent reads (e.g., `apply_patch` tests). +pub struct MutableMockSandbox { + pub files: Mutex>, +} + +impl MutableMockSandbox { + pub fn new(files: HashMap) -> Self { + Self { + files: Mutex::new(files), + } + } +} + +#[async_trait] +impl Sandbox for MutableMockSandbox { + async fn read_file( + &self, + path: &str, + _offset: Option, + _limit: Option, + ) -> Result { + self.files + .lock() + .expect("files lock poisoned") + .get(path) + .cloned() + .ok_or_else(|| format!("File not found: {path}")) + } + + async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { + self.files + .lock() + .expect("files lock poisoned") + .insert(path.to_string(), content.to_string()); + Ok(()) + } + + async fn delete_file(&self, path: &str) -> Result<(), String> { + self.files.lock().expect("files lock poisoned").remove(path); + Ok(()) + } + + async fn file_exists(&self, path: &str) -> Result { + Ok(self + .files + .lock() + .expect("files lock poisoned") + .contains_key(path)) + } + + async fn list_directory( + &self, + _path: &str, + _depth: Option, + ) -> Result, String> { + Ok(vec![]) + } + + async fn exec_command( + &self, + _command: &str, + _timeout_ms: u64, + _working_dir: Option<&str>, + _env_vars: Option<&std::collections::HashMap>, + _cancel_token: Option, + ) -> Result { + Ok(ExecResult { + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + timed_out: false, + duration_ms: 0, + }) + } + + async fn grep( + &self, + pattern: &str, + _path: &str, + _options: &GrepOptions, + ) -> Result, String> { + let files = self.files.lock().expect("files lock poisoned"); + let mut results = Vec::new(); + for (path, content) in files.iter() { + for (i, line) in content.lines().enumerate() { + if line.contains(pattern) { + results.push(format!("{}:{}:{}", path, i + 1, line)); + } + } + } + Ok(results) + } + + async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result, String> { + Ok(vec![]) + } + + async fn download_file_to_local( + &self, + remote_path: &str, + local_path: &std::path::Path, + ) -> Result<(), String> { + let content = self + .files + .lock() + .expect("files lock poisoned") + .get(remote_path) + .cloned() + .ok_or_else(|| format!("File not found: {remote_path}"))?; + 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, content.as_bytes()) + .await + .map_err(|e| format!("Failed to write {}: {e}", local_path.display()))?; + Ok(()) + } + + async fn upload_file_from_local( + &self, + local_path: &std::path::Path, + remote_path: &str, + ) -> Result<(), String> { + let content = tokio::fs::read_to_string(local_path) + .await + .map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?; + self.files + .lock() + .expect("files lock poisoned") + .insert(remote_path.to_string(), content); + Ok(()) + } + + async fn initialize(&self) -> Result<(), String> { + Ok(()) + } + + async fn cleanup(&self) -> Result<(), String> { + Ok(()) + } + + fn working_directory(&self) -> &'static str { + "/work" + } + + fn platform(&self) -> &'static str { + "linux" + } + + fn os_version(&self) -> String { + "Linux 6.1.0".into() + } +} diff --git a/lib/crates/fabro-sprites/Cargo.toml b/lib/crates/fabro-sprites/Cargo.toml deleted file mode 100644 index bf21d4663..000000000 --- a/lib/crates/fabro-sprites/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "fabro-sprites" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "Sprites (Fly.io) VM sandbox for Fabro agent tool operations" - -[lib] -doctest = false - -[dependencies] -fabro-agent = { path = "../fabro-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 -shlex = "1" - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util", "macros"] } -tempfile = "3" diff --git a/lib/crates/fabro-sprites/tests/e2e.rs b/lib/crates/fabro-sprites/tests/e2e.rs deleted file mode 100644 index d43035003..000000000 --- a/lib/crates/fabro-sprites/tests/e2e.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! E2E tests against the live Sprites service. -//! -//! Requires an authenticated `sprite` CLI. Run with: -//! cargo test -p arc-sprites --test e2e -- --ignored - -use fabro_agent::sandbox::Sandbox; -use fabro_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("fabro-"), - "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(()) -} diff --git a/lib/crates/fabro-ssh/Cargo.toml b/lib/crates/fabro-ssh/Cargo.toml deleted file mode 100644 index 9109d9aed..000000000 --- a/lib/crates/fabro-ssh/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "fabro-ssh" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "Generic SSH sandbox for Fabro agent tool operations" - -[lib] -doctest = false - -[dependencies] -fabro-agent = { path = "../fabro-agent" } -fabro-config = { path = "../fabro-config" } -fabro-github = { path = "../fabro-github" } -async-trait.workspace = true -tokio.workspace = true -tokio-util.workspace = true -openssh.workspace = true -serde_json.workspace = true -base64.workspace = true -tracing.workspace = true -serde.workspace = true -shlex = "1" - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util", "macros"] } -tempfile = "3" diff --git a/lib/crates/fabro-workflows/Cargo.toml b/lib/crates/fabro-workflows/Cargo.toml index bd153cbeb..994175572 100644 --- a/lib/crates/fabro-workflows/Cargo.toml +++ b/lib/crates/fabro-workflows/Cargo.toml @@ -14,7 +14,7 @@ doctest = false [features] default = [] -exedev = ["dep:fabro-exe", "fabro-config/exedev"] +exedev = ["fabro-sandbox/exe", "fabro-config/exedev"] [dependencies] anyhow.workspace = true @@ -25,13 +25,11 @@ fabro-graphviz = { path = "../fabro-graphviz" } fabro-hooks = { path = "../fabro-hooks" } fabro-validate = { path = "../fabro-validate" } fabro-devcontainer = { path = "../fabro-devcontainer" } -fabro-exe = { path = "../fabro-exe", optional = true } -fabro-ssh = { path = "../fabro-ssh" } +fabro-sandbox = { path = "../fabro-sandbox", features = ["ssh", "daytona"] } fabro-mcp = { path = "../fabro-mcp" } fabro-github = { path = "../fabro-github" } fabro-interview = { path = "../fabro-interview" } fabro-util = { path = "../fabro-util" } -fabro-daytona = { path = "../fabro-daytona" } fabro-git-storage = { path = "../fabro-git-storage" } fabro-llm = { path = "../fabro-llm" } fabro-retro = { path = "../fabro-retro" } diff --git a/lib/crates/fabro-workflows/src/devcontainer_bridge.rs b/lib/crates/fabro-workflows/src/devcontainer_bridge.rs index bd89da61f..46856ad83 100644 --- a/lib/crates/fabro-workflows/src/devcontainer_bridge.rs +++ b/lib/crates/fabro-workflows/src/devcontainer_bridge.rs @@ -5,7 +5,7 @@ use sha2::{Digest, Sha256}; use fabro_devcontainer::DevcontainerConfig; use crate::event::{EventEmitter, WorkflowRunEvent}; -use fabro_daytona::{DaytonaSnapshotConfig, DockerfileSource}; +use fabro_sandbox::daytona::{DaytonaSnapshotConfig, DockerfileSource}; /// Compute a deterministic snapshot name from Dockerfile content. pub fn snapshot_name_for_dockerfile(dockerfile: &str) -> String { diff --git a/lib/crates/fabro-workflows/src/engine.rs b/lib/crates/fabro-workflows/src/engine.rs index 3f378f9b1..87df0908d 100644 --- a/lib/crates/fabro-workflows/src/engine.rs +++ b/lib/crates/fabro-workflows/src/engine.rs @@ -721,7 +721,7 @@ pub async fn git_push_host( github_app: &Option, label: &str, ) -> bool { - let (origin_url, _) = match fabro_daytona::detect_repo_info(repo_path) { + let (origin_url, _) = match fabro_sandbox::daytona::detect_repo_info(repo_path) { Ok(info) => info, Err(e) => { tracing::warn!(error = %e, label, "Cannot detect origin for push"); diff --git a/lib/crates/fabro-workflows/src/sandbox_reconnect.rs b/lib/crates/fabro-workflows/src/sandbox_reconnect.rs index cd492d377..0f5151e13 100644 --- a/lib/crates/fabro-workflows/src/sandbox_reconnect.rs +++ b/lib/crates/fabro-workflows/src/sandbox_reconnect.rs @@ -40,7 +40,7 @@ pub async fn reconnect(record: &SandboxRecord) -> Result Result { @@ -67,19 +67,19 @@ pub async fn reconnect(record: &SandboxRecord) -> Result bail!("Unknown sandbox provider: {other}"), diff --git a/lib/crates/fabro-workflows/tests/daytona_integration.rs b/lib/crates/fabro-workflows/tests/daytona_integration.rs index 2b0bf222e..ada96fbd9 100644 --- a/lib/crates/fabro-workflows/tests/daytona_integration.rs +++ b/lib/crates/fabro-workflows/tests/daytona_integration.rs @@ -8,9 +8,9 @@ use std::path::Path; use std::sync::Arc; use fabro_agent::Sandbox; -use fabro_daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_llm::provider::Provider; +use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; use fabro_workflows::artifact::sync_artifacts_to_env; use fabro_workflows::checkpoint::Checkpoint; use fabro_workflows::context::Context; @@ -230,7 +230,7 @@ async fn daytona_full_lifecycle() { #[tokio::test] #[ignore] async fn daytona_snapshot_sandbox() { - use fabro_daytona::DaytonaSnapshotConfig; + use fabro_sandbox::daytona::DaytonaSnapshotConfig; dotenvy::dotenv().ok(); @@ -241,7 +241,7 @@ async fn daytona_snapshot_sandbox() { cpu: Some(2), memory: Some(4), disk: Some(10), - dockerfile: Some(fabro_daytona::DockerfileSource::Inline( + dockerfile: Some(fabro_sandbox::daytona::DockerfileSource::Inline( "FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(), )), }),