mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
0f066b0a78
commit
9b1cc75b20
40 changed files with 3344 additions and 3664 deletions
123
Cargo.lock
generated
123
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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}"))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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();
|
||||
}
|
||||
61
lib/crates/fabro-sandbox/Cargo.toml
Normal file
61
lib/crates/fabro-sandbox/Cargo.toml
Normal 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
|
||||
|
|
@ -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);
|
||||
958
lib/crates/fabro-sandbox/src/docker.rs
Normal file
958
lib/crates/fabro-sandbox/src/docker.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
@ -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 {
|
||||
37
lib/crates/fabro-sandbox/src/lib.rs
Normal file
37
lib/crates/fabro-sandbox/src/lib.rs
Normal 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};
|
||||
889
lib/crates/fabro-sandbox/src/local.rs
Normal file
889
lib/crates/fabro-sandbox/src/local.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
272
lib/crates/fabro-sandbox/src/read_guard.rs
Normal file
272
lib/crates/fabro-sandbox/src/read_guard.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
583
lib/crates/fabro-sandbox/src/sandbox.rs
Normal file
583
lib/crates/fabro-sandbox/src/sandbox.rs
Normal 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'");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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,
|
||||
|
|
@ -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]
|
||||
|
|
@ -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 {
|
||||
388
lib/crates/fabro-sandbox/src/test_support.rs
Normal file
388
lib/crates/fabro-sandbox/src/test_support.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue