mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
86935bbb2b
commit
60dad3c1cd
8 changed files with 1135 additions and 22 deletions
299
Cargo.lock
generated
299
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
798
crates/agent/src/docker_env.rs
Normal file
798
crates/agent/src/docker_env.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
10
docker/Dockerfile.agent
Normal 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"]
|
||||
Loading…
Add table
Reference in a new issue