Create fabro-sandbox crate, consolidating Sandbox trait and all implementations

Extract the Sandbox trait, types, and all sandbox implementations from
fabro-agent and four separate crates (fabro-exe, fabro-ssh, fabro-sprites,
fabro-daytona) into a single fabro-sandbox crate. This cleans up the
dependency graph — implementation crates no longer pull in the full
fabro-agent just for the trait.

The new crate uses feature flags (local, docker, ssh, exe, sprites,
daytona, test-support) to gate each implementation. The shell_quote()
helper is unified into a single shared implementation, eliminating four
duplicate copies.

fabro-agent now re-exports all sandbox types from fabro-sandbox for
backward compatibility. The four absorbed crates are removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-19 20:06:19 -04:00
parent 0f066b0a78
commit 9b1cc75b20
No known key found for this signature in database
40 changed files with 3344 additions and 3664 deletions

123
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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<String>,
/// Additional `"host_path:container_path"` bind mounts.
pub extra_mounts: Vec<String>,
/// Memory limit in bytes. `None` = unlimited.
pub memory_limit: Option<i64>,
/// CPU quota (microseconds per 100ms period). `None` = unlimited.
pub cpu_quota: Option<i64>,
/// 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<String>,
}
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<String>,
cached_platform: std::sync::OnceLock<String>,
cached_os_version: std::sync::OnceLock<String>,
rg_available: tokio::sync::OnceCell<bool>,
event_callback: Option<SandboxEventCallback>,
}
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<Self, String> {
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<std::path::PathBuf, String> {
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<String>,
working_dir: Option<&str>,
env: Option<Vec<String>>,
) -> 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<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
let start = Instant::now();
let effective_dir = working_dir.map_or_else(
|| self.config.container_mount_point.clone(),
ToString::to_string,
);
let env: Option<Vec<String>> =
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::<CreateContainerOptions<String>>, container_config)
.await
.map_err(|e| format!("Failed to create container: {e}"))?;
let id = container.id.clone();
self.docker
.start_container(&id, None::<StartContainerOptions<String>>)
.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<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
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<usize>,
limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
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<usize>,
) -> Result<Vec<DirEntry>, 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<DirEntry> = 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<u64> = 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<Vec<String>, 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<String> = result
.stdout
.lines()
.map(String::from)
.filter(|l| !l.is_empty())
.collect();
Ok(results)
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, 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<String> = 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<dyn Sandbox> = 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};

View file

@ -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<SandboxEventCallback>,
rg_available: std::sync::OnceLock<bool>,
}
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<usize>,
limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
let full_path = self.resolve_path(path);
Ok(full_path.exists())
}
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<DirEntry>, 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<DirEntry>,
) -> Result<(), String> {
let mut dir_entries: Vec<std::fs::DirEntry> = 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<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
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<Vec<String>, 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<String> = stdout
.lines()
.map(String::from)
.filter(|l| !l.is_empty())
.collect();
Ok(results)
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, 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<String> = 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<Mutex<Vec<SandboxEvent>>> = 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<Mutex<Vec<SandboxEvent>>> = 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<u8> = (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;

View file

@ -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<dyn Sandbox>,
read_set: Mutex<HashSet<String>>,
}
impl ReadBeforeWriteSandbox {
pub fn new(inner: Arc<dyn Sandbox>) -> 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<String> = 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<String, String>) -> 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;

View file

@ -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<dyn Sandbox>`. 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<usize>, limit: Option<usize>) -> Result<String, String> {
/// // 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<bool, String> {
self.$field.file_exists(path).await
}
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<$crate::sandbox::DirEntry>, 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<String, String>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> 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<Vec<String>, 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<Option<String>, 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<Option<(String, std::collections::HashMap<String, String>)>, String> {
self.$field.get_preview_url(port).await
}
async fn read_file(
&self,
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
self.$field.read_file(path, offset, limit).await
}
async fn grep(
&self,
pattern: &str,
path: &str,
options: &$crate::sandbox::GrepOptions,
) -> Result<Vec<String>, 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<String>,
cpu: Option<f64>,
memory: Option<f64>,
url: Option<String>,
},
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<String>,
},
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<dyn Fn(SandboxEvent) + Send + Sync>;
/// 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<usize>, limit: Option<usize>) -> 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<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct GrepOptions {
pub glob_filter: Option<String>,
pub case_insensitive: bool,
pub max_results: Option<usize>,
}
#[async_trait]
pub trait Sandbox: Send + Sync {
async fn read_file(
&self,
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String>;
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<bool, String>;
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<DirEntry>, String>;
async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String>;
async fn grep(
&self,
pattern: &str,
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String>;
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, 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<Option<String>, 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<Option<(String, HashMap<String, String>)>, 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<dyn Sandbox> = 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<dyn Sandbox> = 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<dyn Sandbox> = 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;

View file

@ -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<String, String>,
pub exec_result: ExecResult,
pub grep_results: Vec<String>,
pub glob_results: Vec<String>,
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<Vec<(String, String)>>,
/// Captures the `timeout_ms` argument from `exec_command` calls.
pub captured_timeout: Mutex<Option<u64>>,
/// Captures the `command` argument from `exec_command` calls.
pub captured_command: Mutex<Option<String>>,
/// Captures the `env_vars` argument from `exec_command` calls.
pub captured_env_vars: Mutex<Option<HashMap<String, String>>>,
pub event_callback: Option<crate::sandbox::SandboxEventCallback>,
}
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<usize>,
limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
Ok(self.files.contains_key(path))
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
_working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
*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<Vec<String>, String> {
Ok(self.grep_results.clone())
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, 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<HashMap<String, String>>,
}
impl MutableMockSandbox {
pub fn new(files: HashMap<String, String>) -> Self {
Self {
files: Mutex::new(files),
}
}
}
#[async_trait]
impl Sandbox for MutableMockSandbox {
async fn read_file(
&self,
path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
Ok(self
.files
.lock()
.expect("files lock poisoned")
.contains_key(path))
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
_command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
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<Vec<String>, 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<Vec<String>, 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 ---

View file

@ -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"] }

View file

@ -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,

View file

@ -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()),
}),

View file

@ -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" }

View file

@ -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

View file

@ -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}"))?;

View file

@ -281,7 +281,7 @@ fn resolve_worktree_mode(
fn resolve_daytona_config(
run_cfg: Option<&WorkflowRunConfig>,
run_defaults: &RunDefaults,
) -> Option<fabro_daytona::DaytonaConfig> {
) -> Option<fabro_sandbox::daytona::DaytonaConfig> {
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<fabro_exe::ExeConfig> {
) -> Option<fabro_sandbox::exe::ExeConfig> {
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<fabro_exe::GitCloneParams> {
let (detected_url, branch) = match fabro_daytona::detect_repo_info(cwd) {
fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::exe::GitCloneParams> {
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<fabro_exe::GitClone
}
};
let url = fabro_github::ssh_url_to_https(&detected_url);
Some(fabro_exe::GitCloneParams { url, branch })
Some(fabro_sandbox::exe::GitCloneParams { url, branch })
}
/// Resolve SSH sandbox config: TOML config > run defaults.
fn resolve_ssh_config(
run_cfg: Option<&WorkflowRunConfig>,
run_defaults: &RunDefaults,
) -> Option<fabro_ssh::SshConfig> {
) -> Option<fabro_sandbox::ssh::SshConfig> {
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<fabro_ssh::GitCloneParams> {
let (detected_url, branch) = match fabro_daytona::detect_repo_info(cwd) {
fn resolve_ssh_clone_params(cwd: &std::path::Path) -> Option<fabro_sandbox::ssh::GitCloneParams> {
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<fabro_ssh::GitClone
}
};
let url = fabro_github::ssh_url_to_https(&detected_url);
Some(fabro_ssh::GitCloneParams { url, branch })
Some(fabro_sandbox::ssh::GitCloneParams { url, branch })
}
/// Resolve the fallback chain from config.
@ -564,9 +564,10 @@ pub async fn run_command(
let preserve_sandbox =
resolve_preserve_sandbox(args.preserve_sandbox, run_cfg.as_ref(), &run_defaults);
let original_cwd = std::env::current_dir()?;
let (origin_url, detected_base_branch) = fabro_daytona::detect_repo_info(&original_cwd)
.map(|(url, branch)| (Some(url), branch))
.unwrap_or((None, None));
let (origin_url, detected_base_branch) =
fabro_sandbox::daytona::detect_repo_info(&original_cwd)
.map(|(url, branch)| (Some(url), branch))
.unwrap_or((None, None));
let git_status =
fabro_workflows::git::sync_status(&original_cwd, "origin", detected_base_branch.as_deref());
@ -1061,7 +1062,7 @@ pub async fn run_command(
}
SandboxProvider::Daytona => {
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<dyn Sandbox>),
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<dyn Sandbox>)
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<dyn Sandbox>)
}
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<dyn Sandbox>)
}
None => Err("SSH sandbox requires [sandbox.ssh] config".to_string()),

View file

@ -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}"))?;

View file

@ -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

View file

@ -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"

View file

@ -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();
}

View file

@ -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

View file

@ -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);

View file

@ -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<String>,
/// Additional `"host_path:container_path"` bind mounts.
pub extra_mounts: Vec<String>,
/// Memory limit in bytes. `None` = unlimited.
pub memory_limit: Option<i64>,
/// CPU quota (microseconds per 100ms period). `None` = unlimited.
pub cpu_quota: Option<i64>,
/// 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<String>,
}
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<String>,
cached_platform: std::sync::OnceLock<String>,
cached_os_version: std::sync::OnceLock<String>,
rg_available: tokio::sync::OnceCell<bool>,
event_callback: Option<SandboxEventCallback>,
}
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<Self, String> {
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<std::path::PathBuf, String> {
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<String>,
working_dir: Option<&str>,
env: Option<Vec<String>>,
) -> 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<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
let start = Instant::now();
let effective_dir = working_dir.map_or_else(
|| self.config.container_mount_point.clone(),
ToString::to_string,
);
let env: Option<Vec<String>> =
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::<CreateContainerOptions<String>>, container_config)
.await
.map_err(|e| format!("Failed to create container: {e}"))?;
let id = container.id.clone();
self.docker
.start_container(&id, None::<StartContainerOptions<String>>)
.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<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
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<usize>,
limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
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<usize>,
) -> Result<Vec<DirEntry>, 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<DirEntry> = 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<u64> = 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<Vec<String>, 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<String> = result
.stdout
.lines()
.map(String::from)
.filter(|l| !l.is_empty())
.collect();
Ok(results)
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, 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<String> = 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<dyn Sandbox> = 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();
}
}

View file

@ -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(

View file

@ -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 {

View file

@ -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};

View file

@ -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<SandboxEventCallback>,
rg_available: std::sync::OnceLock<bool>,
}
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<usize>,
limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
let full_path = self.resolve_path(path);
Ok(full_path.exists())
}
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<DirEntry>, 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<DirEntry>,
) -> Result<(), String> {
let mut dir_entries: Vec<std::fs::DirEntry> = 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<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
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<Vec<String>, 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<String> = stdout
.lines()
.map(String::from)
.filter(|l| !l.is_empty())
.collect();
Ok(results)
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, 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<String> = 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<Mutex<Vec<SandboxEvent>>> = 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<Mutex<Vec<SandboxEvent>>> = 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<u8> = (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();
}
}

View file

@ -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<dyn Sandbox>,
read_set: Mutex<HashSet<String>>,
}
impl ReadBeforeWriteSandbox {
pub fn new(inner: Arc<dyn Sandbox>) -> 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<String> = 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<String, String>) -> 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());
}
}

View file

@ -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<dyn Sandbox>`. 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<usize>, limit: Option<usize>) -> Result<String, String> {
/// // 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<bool, String> {
self.$field.file_exists(path).await
}
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<$crate::DirEntry>, 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<String, String>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> 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<Vec<String>, 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<Option<String>, 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<Option<(String, std::collections::HashMap<String, String>)>, String> {
self.$field.get_preview_url(port).await
}
async fn read_file(
&self,
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
self.$field.read_file(path, offset, limit).await
}
async fn grep(
&self,
pattern: &str,
path: &str,
options: &$crate::GrepOptions,
) -> Result<Vec<String>, 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<String>,
cpu: Option<f64>,
memory: Option<f64>,
url: Option<String>,
},
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<String>,
},
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<dyn Fn(SandboxEvent) + Send + Sync>;
/// 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<usize>, limit: Option<usize>) -> 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<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct GrepOptions {
pub glob_filter: Option<String>,
pub case_insensitive: bool,
pub max_results: Option<usize>,
}
#[async_trait]
pub trait Sandbox: Send + Sync {
async fn read_file(
&self,
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String>;
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<bool, String>;
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> Result<Vec<DirEntry>, String>;
async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String>;
async fn grep(
&self,
pattern: &str,
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String>;
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, 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<Option<String>, 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<Option<(String, HashMap<String, String>)>, 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'");
}
}

View file

@ -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)]

View file

@ -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,

View file

@ -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<u8>,
@ -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<Box<dyn SshRunner>>,
@ -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]

View file

@ -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 {

View file

@ -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<String, String>,
pub exec_result: ExecResult,
pub grep_results: Vec<String>,
pub glob_results: Vec<String>,
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<Vec<(String, String)>>,
/// Captures the `timeout_ms` argument from `exec_command` calls.
pub captured_timeout: Mutex<Option<u64>>,
/// Captures the `command` argument from `exec_command` calls.
pub captured_command: Mutex<Option<String>>,
/// Captures the `env_vars` argument from `exec_command` calls.
pub captured_env_vars: Mutex<Option<HashMap<String, String>>>,
pub event_callback: Option<SandboxEventCallback>,
}
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<usize>,
limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
Ok(self.files.contains_key(path))
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
_working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
*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<Vec<String>, String> {
Ok(self.grep_results.clone())
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, 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<HashMap<String, String>>,
}
impl MutableMockSandbox {
pub fn new(files: HashMap<String, String>) -> Self {
Self {
files: Mutex::new(files),
}
}
}
#[async_trait]
impl Sandbox for MutableMockSandbox {
async fn read_file(
&self,
path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
Ok(self
.files
.lock()
.expect("files lock poisoned")
.contains_key(path))
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
_command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
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<Vec<String>, 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<Vec<String>, 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()
}
}

View file

@ -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"

View file

@ -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(())
}

View file

@ -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"

View file

@ -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" }

View file

@ -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 {

View file

@ -721,7 +721,7 @@ pub async fn git_push_host(
github_app: &Option<fabro_github::GitHubAppCredentials>,
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");

View file

@ -40,7 +40,7 @@ pub async fn reconnect(record: &SandboxRecord) -> Result<Box<dyn fabro_agent::sa
.as_deref()
.context("Daytona sandbox record missing identifier (sandbox name)")?;
let sandbox = fabro_daytona::DaytonaSandbox::reconnect(name)
let sandbox = fabro_sandbox::daytona::DaytonaSandbox::reconnect(name)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(Box::new(sandbox))
@ -52,13 +52,13 @@ pub async fn reconnect(record: &SandboxRecord) -> Result<Box<dyn fabro_agent::sa
.as_deref()
.context("Exe sandbox record missing data_host")?;
let data_ssh = fabro_exe::OpensshRunner::connect(data_host)
let data_ssh = fabro_sandbox::exe::OpensshRunner::connect(data_host)
.await
.map_err(|e| {
anyhow::anyhow!("Failed to connect to exe sandbox '{data_host}': {e}")
})?;
let sandbox = fabro_exe::ExeSandbox::from_existing(Box::new(data_ssh));
let sandbox = fabro_sandbox::exe::ExeSandbox::from_existing(Box::new(data_ssh));
Ok(Box::new(sandbox))
}
"ssh" => {
@ -67,19 +67,19 @@ pub async fn reconnect(record: &SandboxRecord) -> Result<Box<dyn fabro_agent::sa
.as_deref()
.context("SSH sandbox record missing data_host (destination)")?;
let ssh = fabro_ssh::OpensshRunner::connect(destination, None)
let ssh = fabro_sandbox::ssh::OpensshRunner::connect(destination, None)
.await
.map_err(|e| {
anyhow::anyhow!("Failed to connect to SSH sandbox '{destination}': {e}")
})?;
let config = fabro_ssh::SshConfig {
let config = fabro_sandbox::ssh::SshConfig {
destination: destination.to_string(),
working_directory: record.working_directory.clone(),
config_file: None,
preview_url_base: None,
};
let sandbox = fabro_ssh::SshSandbox::from_existing(Box::new(ssh), config);
let sandbox = fabro_sandbox::ssh::SshSandbox::from_existing(Box::new(ssh), config);
Ok(Box::new(sandbox))
}
other => bail!("Unknown sandbox provider: {other}"),

View file

@ -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(),
)),
}),