Add Daytona execution environment and replace --docker with --execution-env

Replace the `--docker` boolean flag with `--execution-env <local|docker|daytona>`
to support three execution environments. The new `DaytonaExecutionEnvironment`
uses the Daytona cloud sandbox SDK to run agent tools remotely, auto-cloning the
current git repo into the sandbox via `gh auth token` credentials.

Setup commands from TOML task configs now run inside the execution environment
(via `exec_command` after `initialize()`) rather than locally, so they work
correctly for both Docker and Daytona sandboxes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 306da8ff6de1
This commit is contained in:
Bryan Helmkamp 2026-02-26 18:35:33 -05:00
parent 93ab7b8dbd
commit 8d593ff68e
12 changed files with 1041 additions and 41 deletions

View file

@ -1,3 +1,4 @@
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GEMINI_API_KEY=
export ANTHROPIC_API_KEY=
export OPENAI_API_KEY=
export GEMINI_API_KEY=
export DAYTONA_API_KEY=

336
Cargo.lock generated
View file

@ -198,10 +198,12 @@ dependencies = [
"axum",
"chrono",
"clap",
"daytona-sdk",
"dialoguer",
"dirs",
"dotenvy",
"futures",
"git2",
"http-body-util",
"llm",
"nom",
@ -215,6 +217,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"toml",
"tower",
"uuid",
@ -549,6 +552,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@ -590,12 +603,93 @@ dependencies = [
"typenum",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust#572e584d06ccc0c6f110679eacc2d3f8d45c03c6"
dependencies = [
"reqwest 0.12.28",
"reqwest-middleware",
"serde",
"serde_json",
"serde_repr",
"serde_with",
"url",
]
[[package]]
name = "daytona-sdk"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust#572e584d06ccc0c6f110679eacc2d3f8d45c03c6"
dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
"futures-util",
"reqwest 0.12.28",
"reqwest-middleware",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"url",
]
[[package]]
name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust#572e584d06ccc0c6f110679eacc2d3f8d45c03c6"
dependencies = [
"reqwest 0.12.28",
"reqwest-middleware",
"serde",
"serde_json",
"serde_repr",
"url",
]
[[package]]
name = "deranged"
version = "0.5.8"
@ -699,6 +793,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -808,6 +911,21 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@ -1220,7 +1338,23 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.6",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
@ -1241,9 +1375,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -1372,6 +1508,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@ -1662,6 +1804,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@ -1679,6 +1831,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.2.1",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nom"
version = "7.1.3"
@ -1792,6 +1961,32 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
@ -2210,16 +2405,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@ -2230,6 +2431,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@ -2240,7 +2442,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.6",
]
[[package]]
@ -2282,6 +2484,21 @@ dependencies = [
"web-sys",
]
[[package]]
name = "reqwest-middleware"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e"
dependencies = [
"anyhow",
"async-trait",
"http",
"reqwest 0.12.28",
"serde",
"thiserror 1.0.69",
"tower-service",
]
[[package]]
name = "ring"
version = "0.17.14"
@ -2358,7 +2575,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
@ -2458,7 +2675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@ -2591,9 +2808,22 @@ dependencies = [
"schemars 1.2.1",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "sha1"
version = "0.10.6"
@ -2710,6 +2940,27 @@ dependencies = [
"syn",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tabwriter"
version = "1.4.1"
@ -2877,6 +3128,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@ -2899,6 +3160,22 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@ -3037,12 +3314,37 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
@ -3085,6 +3387,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@ -3323,6 +3631,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
@ -3404,6 +3721,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"

View file

@ -34,3 +34,4 @@ walkdir = "2"
regex = "1"
aho-corasick = "1"
toml = "0.8"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", package = "daytona-sdk" }

View file

@ -41,6 +41,9 @@ nom = "7"
toml.workspace = true
dirs = "6"
dialoguer.workspace = true
daytona-sdk.workspace = true
git2.workspace = true
tokio-util.workspace = true
axum = { version = "0.8", optional = true }
tower = { version = "0.5", optional = true }
tokio-stream = { workspace = true, optional = true, features = ["sync"] }

View file

@ -12,6 +12,7 @@ use agent::{
use llm::client::Client;
use terminal::Styles;
use crate::cli::ExecutionEnvKind;
use crate::context::Context;
use crate::error::AttractorError;
use crate::graph::Node;
@ -24,24 +25,27 @@ pub struct AgentBackend {
provider: Option<String>,
verbose: u8,
styles: &'static Styles,
docker: bool,
execution_env: ExecutionEnvKind,
setup_commands: Vec<String>,
}
impl AgentBackend {
#[must_use]
pub const fn new(
pub fn new(
model: String,
provider: Option<String>,
verbose: u8,
styles: &'static Styles,
docker: bool,
execution_env: ExecutionEnvKind,
setup_commands: Vec<String>,
) -> Self {
Self {
model,
provider,
verbose,
styles,
docker,
execution_env,
setup_commands,
}
}
@ -146,17 +150,27 @@ impl CodergenBackend for AgentBackend {
let profile = self.build_profile();
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let exec_env: Arc<dyn ExecutionEnvironment> = if self.docker {
let config = DockerConfig {
host_working_directory: cwd.to_string_lossy().to_string(),
..DockerConfig::default()
};
Arc::new(
DockerExecutionEnvironment::new(config)
.map_err(|e| AttractorError::Handler(format!("Failed to create Docker environment: {e}")))?,
)
} else {
Arc::new(LocalExecutionEnvironment::new(cwd))
let exec_env: Arc<dyn ExecutionEnvironment> = match self.execution_env {
ExecutionEnvKind::Docker => {
let config = DockerConfig {
host_working_directory: cwd.to_string_lossy().to_string(),
..DockerConfig::default()
};
Arc::new(
DockerExecutionEnvironment::new(config)
.map_err(|e| AttractorError::Handler(format!("Failed to create Docker environment: {e}")))?,
)
}
ExecutionEnvKind::Daytona => {
let daytona_client = daytona_sdk::Client::new()
.await
.map_err(|e| AttractorError::Handler(format!("Failed to create Daytona client: {e}")))?;
Arc::new(crate::daytona_env::DaytonaExecutionEnvironment::new(
daytona_client,
crate::daytona_env::DaytonaConfig::default(),
))
}
ExecutionEnvKind::Local => Arc::new(LocalExecutionEnvironment::new(cwd)),
};
let config = SessionConfig {
@ -164,6 +178,7 @@ impl CodergenBackend for AgentBackend {
..SessionConfig::default()
};
let exec_env_for_setup = Arc::clone(&exec_env);
let mut session = Session::new(client, profile, exec_env, config);
// File change tracking: shared between spawned task and main fn.
@ -284,6 +299,22 @@ impl CodergenBackend for AgentBackend {
});
session.initialize().await;
// Run setup commands inside the execution environment
for cmd in &self.setup_commands {
let result = exec_env_for_setup
.exec_command(cmd, 300_000, None, None, None)
.await
.map_err(|e| AttractorError::Handler(format!("Setup command failed: {e}")))?;
if result.exit_code != 0 {
return Err(AttractorError::Handler(format!(
"Setup command failed (exit code {}): {cmd}\n{}",
result.exit_code,
result.stderr,
)));
}
}
session.process_input(prompt).await.map_err(|e| {
AttractorError::Handler(format!("Agent session failed: {e}"))
})?;
@ -385,3 +416,23 @@ fn format_tool_args(args: &serde_json::Value) -> String {
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_backend_stores_execution_env_kind() {
let styles = Box::leak(Box::new(Styles::new(false)));
let backend = AgentBackend::new(
"claude-opus-4-6".to_string(),
None,
0,
styles,
ExecutionEnvKind::Daytona,
vec!["npm install".to_string()],
);
assert_eq!(backend.execution_env, ExecutionEnvKind::Daytona);
assert_eq!(backend.setup_commands, vec!["npm install".to_string()]);
}
}

View file

@ -7,8 +7,10 @@ pub mod validate;
use std::path::Path;
use clap::{Args, Parser, Subcommand};
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use terminal::Styles;
use agent::AgentEvent;
@ -16,6 +18,41 @@ use crate::event::PipelineEvent;
use crate::outcome::StageUsage;
use crate::validation::{Diagnostic, Severity};
/// Execution environment for agent tool operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum ExecutionEnvKind {
/// Run tools on the local host (default)
#[default]
Local,
/// Run tools inside a Docker container
Docker,
/// Run tools inside a Daytona cloud sandbox
Daytona,
}
impl fmt::Display for ExecutionEnvKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local => write!(f, "local"),
Self::Docker => write!(f, "docker"),
Self::Daytona => write!(f, "daytona"),
}
}
}
impl FromStr for ExecutionEnvKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"docker" => Ok(Self::Docker),
"daytona" => Ok(Self::Daytona),
other => Err(format!("unknown execution environment: {other}")),
}
}
}
#[derive(Parser)]
#[command(name = "attractor", version, about = "DOT-based pipeline runner for AI workflows")]
pub struct Cli {
@ -67,9 +104,9 @@ pub struct RunArgs {
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
/// Run agent tools inside a Docker container
#[arg(long)]
pub docker: bool,
/// Execution environment for agent tools
#[arg(long, value_enum, default_value_t)]
pub execution_env: ExecutionEnvKind,
}
#[derive(Args)]
@ -101,9 +138,9 @@ pub struct ServeArgs {
#[arg(long)]
pub dry_run: bool,
/// Run agent tools inside a Docker container
#[arg(long)]
pub docker: bool,
/// Execution environment for agent tools
#[arg(long, value_enum, default_value_t)]
pub execution_env: ExecutionEnvKind,
}
/// Read a .dot file from disk.
@ -669,3 +706,29 @@ pub fn format_tokens_human(tokens: i64) -> String {
tokens.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn execution_env_kind_default_is_local() {
assert_eq!(ExecutionEnvKind::default(), ExecutionEnvKind::Local);
}
#[test]
fn execution_env_kind_from_str() {
assert_eq!("local".parse::<ExecutionEnvKind>().unwrap(), ExecutionEnvKind::Local);
assert_eq!("docker".parse::<ExecutionEnvKind>().unwrap(), ExecutionEnvKind::Docker);
assert_eq!("daytona".parse::<ExecutionEnvKind>().unwrap(), ExecutionEnvKind::Daytona);
assert_eq!("LOCAL".parse::<ExecutionEnvKind>().unwrap(), ExecutionEnvKind::Local);
assert!("invalid".parse::<ExecutionEnvKind>().is_err());
}
#[test]
fn execution_env_kind_display() {
assert_eq!(ExecutionEnvKind::Local.to_string(), "local");
assert_eq!(ExecutionEnvKind::Docker.to_string(), "docker");
assert_eq!(ExecutionEnvKind::Daytona.to_string(), "daytona");
}
}

View file

@ -52,17 +52,15 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu
std::env::set_current_dir(dir)
.map_err(|e| anyhow::anyhow!("Failed to set working directory to {dir}: {e}"))?;
}
if let Some(ref setup) = cfg.setup {
let cwd = std::env::current_dir()?;
eprintln!(
"{dim}Running {} setup command(s)…{reset}",
setup.commands.len(),
dim = styles.dim, reset = styles.reset,
);
task_config::run_setup(setup, &cwd).await?;
}
}
// Collect setup commands — they'll be run inside the execution environment
let setup_commands: Vec<String> = task_cfg
.as_ref()
.and_then(|c| c.setup.as_ref())
.map(|s| s.commands.clone())
.unwrap_or_default();
// 1. Parse and validate pipeline
let source = read_dot_file(&dot_path)?;
let source = match task_cfg.as_ref().and_then(|c| c.vars.as_ref()) {
@ -296,7 +294,8 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu
provider.clone(),
args.verbose,
styles,
args.docker,
args.execution_env,
setup_commands.clone(),
)))
}
});

View file

@ -66,7 +66,8 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow::
provider.clone(),
0,
styles,
args.docker,
args.execution_env,
Vec::new(),
)))
}
})

View file

@ -0,0 +1,468 @@
use std::collections::HashMap;
use std::path::Path;
use std::time::Instant;
use agent::execution_env::{format_lines_numbered, DirEntry, ExecResult, ExecutionEnvironment, GrepOptions};
use async_trait::async_trait;
/// Configuration for a Daytona cloud sandbox execution environment.
pub struct DaytonaConfig {
/// Docker image to use for the sandbox.
pub image: String,
/// Working directory inside the sandbox.
pub working_directory: String,
}
impl Default for DaytonaConfig {
fn default() -> Self {
Self {
image: "ubuntu:22.04".to_string(),
working_directory: "/home/daytona/workspace".to_string(),
}
}
}
/// Execution environment that runs all operations inside a Daytona cloud sandbox.
pub struct DaytonaExecutionEnvironment {
config: DaytonaConfig,
client: daytona_sdk::Client,
sandbox: tokio::sync::OnceCell<daytona_sdk::Sandbox>,
}
impl DaytonaExecutionEnvironment {
#[must_use]
pub fn new(client: daytona_sdk::Client, config: DaytonaConfig) -> Self {
Self {
config,
client,
sandbox: tokio::sync::OnceCell::new(),
}
}
/// Resolve a path: relative paths are prepended with the working directory.
fn resolve_path(&self, path: &str) -> String {
if Path::new(path).is_absolute() {
path.to_string()
} else {
format!("{}/{}", self.config.working_directory, path)
}
}
/// Get the sandbox, returning an error if not yet initialized.
fn sandbox(&self) -> Result<&daytona_sdk::Sandbox, String> {
self.sandbox
.get()
.ok_or_else(|| "Daytona sandbox not initialized — call initialize() first".to_string())
}
}
/// Detect the git remote URL and current branch from a local repository.
///
/// Uses `git2` to discover the repo at `path`, reads the `origin` remote URL
/// and the HEAD branch name.
pub fn detect_repo_info(path: &Path) -> Result<(String, Option<String>), String> {
let repo = git2::Repository::discover(path)
.map_err(|e| format!("Failed to discover git repo at {}: {e}", path.display()))?;
let url = repo
.find_remote("origin")
.map_err(|e| format!("Failed to find 'origin' remote: {e}"))?
.url()
.ok_or_else(|| "origin remote URL is not valid UTF-8".to_string())?
.to_string();
let branch = repo
.head()
.ok()
.and_then(|head| head.shorthand().map(String::from));
Ok((url, branch))
}
/// Get a GitHub authentication token via `gh auth token`.
pub fn get_gh_token() -> Result<String, String> {
let output = std::process::Command::new("gh")
.args(["auth", "token"])
.output()
.map_err(|e| format!("Failed to run 'gh auth token': {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"gh auth token failed (exit code {}): {stderr}",
output.status.code().unwrap_or(-1)
));
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if token.is_empty() {
return Err("gh auth token returned empty string".to_string());
}
Ok(token)
}
#[async_trait]
impl ExecutionEnvironment for DaytonaExecutionEnvironment {
async fn initialize(&self) -> Result<(), String> {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let params = daytona_sdk::CreateParams::Image(daytona_sdk::ImageParams {
base: daytona_sdk::SandboxBaseParams {
ephemeral: Some(true),
..Default::default()
},
image: daytona_sdk::ImageSource::Name(self.config.image.clone()),
resources: None,
});
let sandbox = self
.client
.create(params, daytona_sdk::CreateSandboxOptions::default())
.await
.map_err(|e| format!("Failed to create Daytona sandbox: {e}"))?;
// Clone the repo into the sandbox
match detect_repo_info(&cwd) {
Ok((url, branch)) => {
let token = get_gh_token()
.map_err(|e| format!("Failed to get GitHub token for Daytona clone: {e}"))?;
let git_svc = sandbox
.git()
.await
.map_err(|e| format!("Failed to get Daytona git service: {e}"))?;
git_svc
.clone(
&url,
&self.config.working_directory,
daytona_sdk::GitCloneOptions {
branch,
username: Some("x-access-token".to_string()),
password: Some(token),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to clone repo into Daytona sandbox: {e}"))?;
}
Err(e) => {
eprintln!("Warning: could not detect git repo for Daytona clone: {e}");
// Create working directory even without a repo
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get Daytona fs service: {e}"))?;
fs_svc
.create_folder(&self.config.working_directory, None)
.await
.map_err(|e| format!("Failed to create working directory: {e}"))?;
}
}
self.sandbox
.set(sandbox)
.map_err(|_| "Daytona sandbox already initialized".to_string())?;
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
if let Some(sandbox) = self.sandbox.get() {
sandbox
.delete()
.await
.map_err(|e| format!("Failed to delete Daytona sandbox: {e}"))?;
}
Ok(())
}
fn working_directory(&self) -> &str {
&self.config.working_directory
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Linux (Daytona)".to_string()
}
async fn read_file(
&self,
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String, String> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
let bytes = fs_svc
.download_file(&resolved)
.await
.map_err(|e| format!("Failed to read file {resolved}: {e}"))?;
let content =
String::from_utf8(bytes).map_err(|e| format!("File is not valid UTF-8: {e}"))?;
Ok(format_lines_numbered(&content, offset, limit))
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
// Ensure parent directory exists
if let Some(parent) = Path::new(&resolved).parent() {
let parent_str = parent.to_string_lossy();
if parent_str != "/" {
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
let _ = fs_svc.create_folder(&parent_str, None).await;
}
}
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
fs_svc
.upload_file_bytes(&resolved, content.as_bytes())
.await
.map_err(|e| format!("Failed to write file {resolved}: {e}"))?;
Ok(())
}
async fn delete_file(&self, path: &str) -> Result<(), String> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
fs_svc
.delete_file(&resolved, false)
.await
.map_err(|e| format!("Failed to delete file {resolved}: {e}"))?;
Ok(())
}
async fn file_exists(&self, path: &str) -> Result<bool, String> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
match fs_svc.get_file_info(&resolved).await {
Ok(_) => Ok(true),
Err(daytona_sdk::DaytonaError::NotFound { .. }) => Ok(false),
Err(e) => Err(format!("Failed to check file existence {resolved}: {e}")),
}
}
async fn list_directory(
&self,
path: &str,
_depth: Option<usize>,
) -> Result<Vec<DirEntry>, String> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(path);
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
let files = fs_svc
.list_files(&resolved)
.await
.map_err(|e| format!("Failed to list directory {resolved}: {e}"))?;
Ok(files
.into_iter()
.map(|f| DirEntry {
name: f.name,
is_dir: f.is_dir,
size: if f.size > 0 {
Some(f.size as u64)
} else {
None
},
})
.collect())
}
async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
working_dir: Option<&str>,
_env_vars: Option<&HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<ExecResult, String> {
let sandbox = self.sandbox()?;
let start = Instant::now();
let cwd = working_dir
.map(|d| self.resolve_path(d))
.unwrap_or_else(|| self.config.working_directory.clone());
let process_svc = sandbox
.process()
.await
.map_err(|e| format!("Failed to get process service: {e}"))?;
let options = daytona_sdk::ExecuteCommandOptions {
cwd: Some(cwd),
timeout: Some(std::time::Duration::from_millis(timeout_ms)),
..Default::default()
};
let result = process_svc
.execute_command(command, options)
.await
.map_err(|e| format!("Failed to execute command: {e}"))?;
let duration_ms = start.elapsed().as_millis() as u64;
// The Daytona SDK returns combined output in `result` field.
// Separate stderr isn't available in the simple execute_command API.
Ok(ExecResult {
stdout: result.result.clone(),
stderr: String::new(),
exit_code: result.exit_code,
timed_out: false,
duration_ms,
})
}
async fn grep(
&self,
pattern: &str,
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String> {
let resolved = self.resolve_path(path);
// Build rg command (same approach as Docker env)
let mut cmd = "rg --line-number --no-heading".to_string();
if options.case_insensitive {
cmd.push_str(" -i");
}
if let Some(ref glob_filter) = options.glob_filter {
cmd.push_str(&format!(" --glob '{glob_filter}'"));
}
if let Some(max) = options.max_results {
cmd.push_str(&format!(" --max-count {max}"));
}
cmd.push_str(&format!(" -- '{}' '{}'", pattern.replace('\'', "'\\''"), resolved));
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
if result.exit_code == 1 {
// rg exits 1 for no matches
return Ok(Vec::new());
}
if result.exit_code != 0 {
return Err(format!("grep failed (exit {}): {}", result.exit_code, result.stderr));
}
Ok(result
.stdout
.lines()
.map(String::from)
.collect())
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String> {
let base = path
.map(|p| self.resolve_path(p))
.unwrap_or_else(|| self.config.working_directory.clone());
let cmd = format!(
"find '{}' -name '{}' -type f | sort",
base.replace('\'', "'\\''"),
pattern.replace('\'', "'\\''"),
);
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
if result.exit_code != 0 {
return Err(format!("glob failed (exit {}): {}", result.exit_code, result.stderr));
}
Ok(result
.stdout
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daytona_config_defaults() {
let config = DaytonaConfig::default();
assert_eq!(config.image, "ubuntu:22.04");
assert_eq!(config.working_directory, "/home/daytona/workspace");
}
#[test]
fn detect_git_remote_from_repo() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let (url, _branch) = detect_repo_info(dir.path()).unwrap();
assert_eq!(url, "https://github.com/org/repo.git");
}
#[test]
fn detect_git_branch_from_repo() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
// Create an initial commit so HEAD points to a branch
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let (_, branch) = detect_repo_info(dir.path()).unwrap();
// git init creates "master" or "main" depending on git config
assert!(branch.is_some());
}
#[test]
#[ignore] // requires `gh` CLI installed and authenticated
fn gh_auth_token_returns_nonempty_string() {
let token = get_gh_token().unwrap();
assert!(!token.is_empty());
}
}

View file

@ -2,6 +2,7 @@ pub mod artifact;
pub mod checkpoint;
pub mod cli;
pub mod condition;
pub mod daytona_env;
pub mod context;
pub mod engine;
pub mod error;

View file

@ -0,0 +1,83 @@
//! Integration tests for `DaytonaExecutionEnvironment`.
//!
//! These tests require a `DAYTONA_API_KEY` environment variable and network access.
//! Run with: `cargo test --package attractor -- --ignored daytona`
use agent::ExecutionEnvironment;
use attractor::daytona_env::{DaytonaConfig, DaytonaExecutionEnvironment};
async fn create_env() -> DaytonaExecutionEnvironment {
dotenvy::dotenv().ok();
let client = daytona_sdk::Client::new()
.await
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?");
DaytonaExecutionEnvironment::new(client, DaytonaConfig::default())
}
#[tokio::test]
#[ignore]
async fn daytona_exec_command() {
let env = create_env().await;
env.initialize().await.unwrap();
let result = env
.exec_command("echo hello", 30_000, None, None, None)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello"));
env.cleanup().await.unwrap();
}
#[tokio::test]
#[ignore]
async fn daytona_file_round_trip() {
let env = create_env().await;
env.initialize().await.unwrap();
let test_path = "test_round_trip.txt";
let content = "Hello from Daytona integration test!";
// Write
env.write_file(test_path, content).await.unwrap();
// Exists
assert!(env.file_exists(test_path).await.unwrap());
// Read
let read_back = env.read_file(test_path, None, None).await.unwrap();
assert!(read_back.contains(content));
// Delete
env.delete_file(test_path).await.unwrap();
assert!(!env.file_exists(test_path).await.unwrap());
env.cleanup().await.unwrap();
}
#[tokio::test]
#[ignore]
async fn daytona_full_lifecycle() {
let env = create_env().await;
// Initialize (creates sandbox + clones repo)
env.initialize().await.unwrap();
// Verify platform
assert_eq!(env.platform(), "linux");
// Verify working directory is accessible
let result = env
.exec_command("pwd", 10_000, None, None, None)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
// List directory
let entries = env.list_directory(".", None).await.unwrap();
assert!(!entries.is_empty());
// Cleanup (deletes sandbox)
env.cleanup().await.unwrap();
}

View file

@ -6586,7 +6586,8 @@ async fn attractor_e2e_with_real_llm() {
None,
0,
&TEST_STYLES,
false,
attractor::cli::ExecutionEnvKind::Local,
Vec::new(),
)) as Box<dyn attractor::handler::codergen::CodergenBackend>)
});