Add DockerExecutionEnvironment for sandboxed agent tool execution

Implements ExecutionEnvironment trait backed by Docker containers via
bollard. Host working directory is bind-mounted; all file ops, commands,
grep, and glob execute inside the container via docker exec. Extracts
shared format_lines_numbered() helper from LocalExecutionEnvironment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 13:29:38 -05:00
parent 86935bbb2b
commit 60dad3c1cd
8 changed files with 1135 additions and 22 deletions

299
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -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<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 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<String>,
cached_platform: std::sync::OnceLock<String>,
cached_os_version: std::sync::OnceLock<String>,
}
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<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(),
})
}
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<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 support.
async fn docker_exec_shell(
&self,
command: &str,
timeout_ms: u64,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
) -> Result<ExecResult, String> {
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<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 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::<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()));
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<String, String>>,
) -> 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)
.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(|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<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
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<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(|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<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())
}
}
#[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<dyn ExecutionEnvironment> =
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();
}
}

View file

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

View file

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

View file

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

10
docker/Dockerfile.agent Normal file
View file

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