diff --git a/Cargo.lock b/Cargo.lock index ba1f86547..1cb470894 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bollard", "chrono", "clap", "dotenvy", @@ -18,6 +19,7 @@ dependencies = [ "llm", "serde", "serde_json", + "tar", "terminal", "thiserror 2.0.18", "tokio", @@ -286,6 +288,50 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bollard" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.47.1-rc.27.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -456,6 +502,16 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "difflib" version = "0.4.0" @@ -485,6 +541,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "email_address" version = "0.2.9" @@ -536,6 +598,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -764,13 +837,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -797,6 +876,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -865,6 +950,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -922,6 +1022,21 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1054,6 +1169,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1183,6 +1309,17 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.1", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1342,6 +1479,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-integer" version = "0.1.46" @@ -1462,7 +1605,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1500,6 +1643,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1612,6 +1761,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1889,6 +2047,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1978,6 +2160,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1990,6 +2183,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "time", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2098,6 +2309,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.25.0" @@ -2161,6 +2383,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2531,7 +2784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -2557,7 +2810,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -2580,6 +2833,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2589,6 +2858,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2909,7 +3184,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -2940,7 +3215,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -2959,7 +3234,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -2975,6 +3250,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 3137bf84c..e3c7d5296 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,5 @@ tokio-util = "0.7" clap = { version = "4", features = ["derive"] } jsonschema = "0.42" chrono = "0.4" +bollard = "0.18" +tar = "0.4" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index c98f4935c..b3e2efbaa 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -9,6 +9,10 @@ readme = "README.md" keywords = ["llm", "ai", "agent", "coding"] categories = ["api-bindings"] +[features] +default = ["docker"] +docker = ["bollard", "tar"] + [lib] doctest = false @@ -32,6 +36,8 @@ async-trait.workspace = true jsonschema.workspace = true chrono.workspace = true glob = "0.3" +bollard = { workspace = true, optional = true } +tar = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/agent/src/docker_env.rs b/crates/agent/src/docker_env.rs new file mode 100644 index 000000000..b2a70d2bb --- /dev/null +++ b/crates/agent/src/docker_env.rs @@ -0,0 +1,798 @@ +use crate::execution_env::{format_lines_numbered, DirEntry, ExecResult, ExecutionEnvironment, GrepOptions}; +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; + +/// Configuration for a Docker-based execution environment. +pub struct DockerConfig { + /// Docker image to use. Default: `"attractor-agent:latest"`. + pub image: String, + /// Host directory to bind-mount into the container. + pub host_working_directory: String, + /// Mount point inside the container. Default: `"/workspace"`. + pub container_mount_point: String, + /// Docker network mode. Default: `Some("bridge")`. + pub network_mode: Option, + /// Additional `"host_path:container_path"` bind mounts. + pub extra_mounts: Vec, + /// Memory limit in bytes. `None` = unlimited. + pub memory_limit: Option, + /// CPU quota (microseconds per 100ms period). `None` = unlimited. + pub cpu_quota: Option, + /// Whether to pull the image if not found locally. Default: `true`. + pub auto_pull: bool, + /// Additional `KEY=VALUE` environment variables for the container. + pub env_vars: Vec, +} + +impl Default for DockerConfig { + fn default() -> Self { + Self { + image: "attractor-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(), + } + } +} + +/// Execution environment 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 DockerExecutionEnvironment { + docker: Docker, + config: DockerConfig, + container_id: tokio::sync::OnceCell, + cached_platform: std::sync::OnceLock, + cached_os_version: std::sync::OnceLock, +} + +impl DockerExecutionEnvironment { + /// Creates a new `DockerExecutionEnvironment`. + /// + /// Validates Docker daemon connectivity but does NOT create a container. + /// Call `initialize()` to create and start the container. + pub fn new(config: DockerConfig) -> Result { + let docker = + Docker::connect_with_local_defaults().map_err(|e| format!("Failed to connect to Docker daemon: {e}"))?; + Ok(Self { + docker, + config, + container_id: tokio::sync::OnceCell::new(), + cached_platform: std::sync::OnceLock::new(), + cached_os_version: std::sync::OnceLock::new(), + }) + } + + 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) + } + } + + /// Executes a command inside the container, returning `(stdout, stderr, exit_code)`. + async fn docker_exec( + &self, + cmd: Vec, + working_dir: Option<&str>, + env: Option>, + ) -> Result<(String, String, i32), String> { + let container_id = self.container_id()?; + + let exec_opts = CreateExecOptions { + cmd: Some(cmd), + attach_stdout: Some(true), + attach_stderr: Some(true), + working_dir: working_dir.map(ToString::to_string), + env: env.map(|e| e.into_iter().collect()), + ..Default::default() + }; + + let exec_instance = self + .docker + .create_exec(container_id, exec_opts) + .await + .map_err(|e| format!("Failed to create exec: {e}"))?; + + let start_result = self + .docker + .start_exec(&exec_instance.id, None) + .await + .map_err(|e| format!("Failed to start exec: {e}"))?; + + let mut stdout = String::new(); + let mut stderr = String::new(); + + if let StartExecResults::Attached { mut output, .. } = start_result { + while let Some(chunk) = output.next().await { + match chunk { + Ok(bollard::container::LogOutput::StdOut { message }) => { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + Ok(bollard::container::LogOutput::StdErr { message }) => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + Ok(_) => {} + Err(e) => return Err(format!("Error reading exec output: {e}")), + } + } + } + + let inspect = self + .docker + .inspect_exec(&exec_instance.id) + .await + .map_err(|e| format!("Failed to inspect exec: {e}"))?; + + let exit_code = inspect.exit_code.unwrap_or(-1) as i32; + Ok((stdout, stderr, exit_code)) + } + + /// Runs a shell command inside the container with timeout support. + async fn docker_exec_shell( + &self, + command: &str, + timeout_ms: u64, + working_dir: Option<&str>, + env_vars: Option<&HashMap>, + ) -> Result { + let start = Instant::now(); + + let effective_dir = working_dir + .map(ToString::to_string) + .unwrap_or_else(|| self.config.container_mount_point.clone()); + + let env: Option> = env_vars.map(|vars| { + vars.iter() + .map(|(k, v)| format!("{k}={v}")) + .collect() + }); + + let cmd = vec![ + "/bin/bash".to_string(), + "-c".to_string(), + command.to_string(), + ]; + + let timeout_duration = std::time::Duration::from_millis(timeout_ms); + let exec_future = self.docker_exec(cmd, Some(&effective_dir), env); + + match tokio::time::timeout(timeout_duration, exec_future).await { + Ok(result) => { + 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, + }) + } + Err(_) => { + 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, + }) + } + } + } + + /// 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 ExecutionEnvironment for DockerExecutionEnvironment { + async fn initialize(&self) -> Result<(), String> { + self.ensure_image().await?; + + let mut binds = vec![format!( + "{}:{}", + self.config.host_working_directory, self.config.container_mount_point + )]; + for extra in &self.config.extra_mounts { + binds.push(extra.clone()); + } + + let host_config = bollard::models::HostConfig { + binds: Some(binds), + network_mode: self.config.network_mode.clone(), + memory: self.config.memory_limit, + cpu_quota: self.config.cpu_quota, + ..Default::default() + }; + + let container_config = Config { + image: Some(self.config.image.clone()), + cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]), + working_dir: Some(self.config.container_mount_point.clone()), + env: if self.config.env_vars.is_empty() { + None + } else { + Some(self.config.env_vars.clone()) + }, + host_config: Some(host_config), + ..Default::default() + }; + + let container = self + .docker + .create_container(None::>, container_config) + .await + .map_err(|e| format!("Failed to create container: {e}"))?; + + let id = container.id.clone(); + + self.docker + .start_container(&id, None::>) + .await + .map_err(|e| format!("Failed to start container: {e}"))?; + + self.container_id + .set(id) + .map_err(|_| "Container already initialized".to_string())?; + + // Verify container is running + let (stdout, _, exit_code) = self + .docker_exec( + vec!["echo".to_string(), "ready".to_string()], + None, + None, + ) + .await?; + + if exit_code != 0 || !stdout.contains("ready") { + return Err("Container health check failed".to_string()); + } + + // Cache platform info + let (uname_output, _, _) = self + .docker_exec( + vec!["uname".to_string(), "-r".to_string()], + None, + None, + ) + .await?; + + let _ = self.cached_platform.set("linux".to_string()); + let _ = self + .cached_os_version + .set(format!("linux {}", uname_output.trim())); + + Ok(()) + } + + async fn cleanup(&self) -> Result<(), String> { + let container_id = match self.container_id.get() { + Some(id) => id.clone(), + None => 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; + + Ok(()) + } + + async fn exec_command( + &self, + command: &str, + timeout_ms: u64, + working_dir: Option<&str>, + env_vars: Option<&HashMap>, + ) -> Result { + let dir = working_dir.map(|d| self.resolve_container_path(d)); + self.docker_exec_shell(command, timeout_ms, dir.as_deref(), env_vars) + .await + } + + async fn read_file( + &self, + path: &str, + offset: Option, + limit: Option, + ) -> Result { + let container_path = self.resolve_container_path(path); + let (stdout, stderr, exit_code) = self + .docker_exec( + vec!["cat".to_string(), container_path.clone()], + None, + None, + ) + .await?; + + if exit_code != 0 { + return Err(format!( + "Failed to read {container_path}: {stderr}" + )); + } + + Ok(format_lines_numbered(&stdout, offset, limit)) + } + + async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { + let container_path = self.resolve_container_path(path); + let container_id = self.container_id()?; + + // Ensure parent directory exists + if let Some(parent) = std::path::Path::new(&container_path).parent() { + let parent_str = parent.to_string_lossy(); + let (_, stderr, exit_code) = self + .docker_exec( + vec![ + "mkdir".to_string(), + "-p".to_string(), + parent_str.to_string(), + ], + None, + None, + ) + .await?; + if exit_code != 0 { + return Err(format!("Failed to create parent dirs for {container_path}: {stderr}")); + } + } + + // Build an in-memory tar archive to upload via bollard API. + // This avoids shell escaping issues with special characters in content. + let mut tar_builder = tar::Builder::new(Vec::new()); + let file_name = std::path::Path::new(&container_path) + .file_name() + .ok_or_else(|| format!("Invalid path: {container_path}"))? + .to_string_lossy() + .to_string(); + + let content_bytes = content.as_bytes(); + let mut header = tar::Header::new_gnu(); + header.set_path(&file_name).map_err(|e| format!("Failed to set tar path: {e}"))?; + header.set_size(content_bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + + tar_builder + .append(&header, content_bytes) + .map_err(|e| format!("Failed to build tar archive: {e}"))?; + + let tar_bytes = tar_builder + .into_inner() + .map_err(|e| format!("Failed to finalize tar archive: {e}"))?; + + let parent_dir = std::path::Path::new(&container_path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "/".to_string()); + + let upload_opts = UploadToContainerOptions { + path: parent_dir, + ..Default::default() + }; + + self.docker + .upload_to_container(container_id, Some(upload_opts), tar_bytes.into()) + .await + .map_err(|e| format!("Failed to upload file to container: {e}")) + } + + async fn delete_file(&self, path: &str) -> Result<(), String> { + let container_path = self.resolve_container_path(path); + let (_, stderr, exit_code) = self + .docker_exec( + vec!["rm".to_string(), "-f".to_string(), container_path.clone()], + None, + None, + ) + .await?; + + if exit_code != 0 { + return Err(format!("Failed to delete {container_path}: {stderr}")); + } + Ok(()) + } + + async fn file_exists(&self, path: &str) -> Result { + let container_path = self.resolve_container_path(path); + let (_, _, exit_code) = self + .docker_exec( + vec!["test".to_string(), "-e".to_string(), container_path], + None, + None, + ) + .await?; + + Ok(exit_code == 0) + } + + async fn list_directory( + &self, + path: &str, + depth: Option, + ) -> Result, String> { + let container_path = self.resolve_container_path(path); + let max_depth = depth.unwrap_or(1); + + // Use find with -printf for structured output: type, size, relative path + let (stdout, stderr, exit_code) = self + .docker_exec( + vec![ + "find".to_string(), + container_path.clone(), + "-mindepth".to_string(), + "1".to_string(), + "-maxdepth".to_string(), + max_depth.to_string(), + "-printf".to_string(), + "%y\t%s\t%P\n".to_string(), + ], + None, + None, + ) + .await?; + + if exit_code != 0 { + return Err(format!( + "Failed to list directory {container_path}: {stderr}" + )); + } + + let mut entries: Vec = stdout + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(3, '\t').collect(); + if parts.len() < 3 { + return None; + } + let file_type = parts[0]; + let size: Option = parts[1].parse().ok(); + let name = parts[2].to_string(); + let is_dir = file_type == "d"; + Some(DirEntry { + name, + is_dir, + size: if is_dir { None } else { size }, + }) + }) + .collect(); + + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(entries) + } + + async fn grep( + &self, + pattern: &str, + path: &str, + options: &GrepOptions, + ) -> Result, String> { + let container_path = self.resolve_container_path(path); + + // Detect ripgrep availability + let (_, _, rg_check) = self + .docker_exec( + vec!["which".to_string(), "rg".to_string()], + None, + None, + ) + .await?; + + let use_rg = rg_check == 0; + + 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) + .await?; + + let results: Vec = result + .stdout + .lines() + .map(String::from) + .filter(|l| !l.is_empty()) + .collect(); + + Ok(results) + } + + async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { + let base_dir = path + .map(|p| self.resolve_container_path(p)) + .unwrap_or_else(|| self.config.container_mount_point.clone()); + + 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) + .await?; + + let results: Vec = result + .stdout + .lines() + .map(String::from) + .filter(|l| !l.is_empty()) + .collect(); + + Ok(results) + } + + fn working_directory(&self) -> &str { + &self.config.container_mount_point + } + + fn platform(&self) -> &str { + self.cached_platform.get().map_or("linux", String::as_str) + } + + fn os_version(&self) -> String { + self.cached_os_version + .get() + .cloned() + .unwrap_or_else(|| "linux".to_string()) + } +} + +#[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) -> DockerConfig { + DockerConfig { + host_working_directory: host_dir.to_string(), + auto_pull: false, + ..Default::default() + } + } + + #[tokio::test] + async fn full_lifecycle() { + let _docker = require_docker(); + let host_dir = std::env::temp_dir().join(format!("docker_env_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&host_dir).unwrap(); + + let config = test_config(host_dir.to_str().unwrap()); + let env: Arc = + Arc::new(DockerExecutionEnvironment::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).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] + 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 = DockerExecutionEnvironment::new(config).unwrap(); + env.initialize().await.unwrap(); + + let result = env.exec_command("sleep 60", 1000, 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] + 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 = DockerExecutionEnvironment::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).await.unwrap(); + assert_eq!(result.stdout, content); + + env.cleanup().await.unwrap(); + std::fs::remove_dir_all(&host_dir).ok(); + } + + #[tokio::test] + 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 = DockerExecutionEnvironment::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] + 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 = DockerExecutionEnvironment::new(config).unwrap(); + env.initialize().await.unwrap(); + + // First cleanup + env.cleanup().await.unwrap(); + // Second cleanup should not error + env.cleanup().await.unwrap(); + + std::fs::remove_dir_all(&host_dir).ok(); + } +} diff --git a/crates/agent/src/execution_env.rs b/crates/agent/src/execution_env.rs index 1b3bc07d5..f80601253 100644 --- a/crates/agent/src/execution_env.rs +++ b/crates/agent/src/execution_env.rs @@ -1,4 +1,23 @@ use async_trait::async_trait; +use std::fmt::Write; + +/// 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. +pub fn format_lines_numbered(content: &str, offset: Option, limit: Option) -> String { + let all_lines: Vec<&str> = content.lines().collect(); + let skip = offset.unwrap_or(0); + let take = limit.unwrap_or(all_lines.len()); + let selected: Vec<&str> = all_lines.into_iter().skip(skip).take(take).collect(); + let width = (skip + selected.len()).to_string().len().max(1); + let mut result = String::new(); + for (i, line) in selected.iter().enumerate() { + let line_num = skip + i + 1; + let _ = writeln!(result, "{line_num:>width$} | {line}"); + } + result +} #[derive(Debug, Clone)] pub struct ExecResult { diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index e6e2c25a1..395ea2d06 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "docker")] +pub mod docker_env; + pub mod cli; pub mod config; pub mod error; @@ -19,8 +22,10 @@ pub mod types; pub use config::{SessionConfig, ToolApprovalFn}; pub use error::AgentError; pub use event::EventEmitter; -pub use execution_env::{DirEntry, ExecResult, ExecutionEnvironment, GrepOptions}; +pub use execution_env::{format_lines_numbered, DirEntry, ExecResult, ExecutionEnvironment, GrepOptions}; pub use history::History; +#[cfg(feature = "docker")] +pub use docker_env::{DockerConfig, DockerExecutionEnvironment}; pub use local_env::LocalExecutionEnvironment; pub use loop_detection::detect_loop; pub use project_docs::discover_project_docs; diff --git a/crates/agent/src/local_env.rs b/crates/agent/src/local_env.rs index 29fd247b8..90919c3d4 100644 --- a/crates/agent/src/local_env.rs +++ b/crates/agent/src/local_env.rs @@ -1,4 +1,4 @@ -use crate::execution_env::{DirEntry, ExecResult, ExecutionEnvironment, GrepOptions}; +use crate::execution_env::{format_lines_numbered, DirEntry, ExecResult, ExecutionEnvironment, GrepOptions}; use async_trait::async_trait; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -50,19 +50,7 @@ impl ExecutionEnvironment for LocalExecutionEnvironment { .await .map_err(|e| format!("Failed to read {}: {e}", full_path.display()))?; - 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(); - - use std::fmt::Write; - 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}"); - } - Ok(result) + Ok(format_lines_numbered(&content, offset, limit)) } async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent new file mode 100644 index 000000000..215097044 --- /dev/null +++ b/docker/Dockerfile.agent @@ -0,0 +1,10 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash coreutils findutils grep git curl ca-certificates \ + build-essential && \ + # Install ripgrep + curl -LO https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep_14.1.1-1_amd64.deb && \ + dpkg -i ripgrep_14.1.1-1_amd64.deb && rm ripgrep_14.1.1-1_amd64.deb && \ + apt-get clean && rm -rf /var/lib/apt/lists/* +WORKDIR /workspace +CMD ["sleep", "infinity"]