diff --git a/.env.example b/.env.example index 095a1fede..8f09eecb9 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f7606a43e..d566fa6e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index f82520f50..6ec124347 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/attractor/Cargo.toml b/crates/attractor/Cargo.toml index 090d0010e..160e28e90 100644 --- a/crates/attractor/Cargo.toml +++ b/crates/attractor/Cargo.toml @@ -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"] } diff --git a/crates/attractor/src/cli/backend.rs b/crates/attractor/src/cli/backend.rs index 5e008218f..211710431 100644 --- a/crates/attractor/src/cli/backend.rs +++ b/crates/attractor/src/cli/backend.rs @@ -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, verbose: u8, styles: &'static Styles, - docker: bool, + execution_env: ExecutionEnvKind, + setup_commands: Vec, } impl AgentBackend { #[must_use] - pub const fn new( + pub fn new( model: String, provider: Option, verbose: u8, styles: &'static Styles, - docker: bool, + execution_env: ExecutionEnvKind, + setup_commands: Vec, ) -> 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 = 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 = 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::>() .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()]); + } +} diff --git a/crates/attractor/src/cli/mod.rs b/crates/attractor/src/cli/mod.rs index 86dbce6b9..8bbaa4926 100644 --- a/crates/attractor/src/cli/mod.rs +++ b/crates/attractor/src/cli/mod.rs @@ -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 { + 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::().unwrap(), ExecutionEnvKind::Local); + assert_eq!("docker".parse::().unwrap(), ExecutionEnvKind::Docker); + assert_eq!("daytona".parse::().unwrap(), ExecutionEnvKind::Daytona); + assert_eq!("LOCAL".parse::().unwrap(), ExecutionEnvKind::Local); + assert!("invalid".parse::().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"); + } +} diff --git a/crates/attractor/src/cli/run.rs b/crates/attractor/src/cli/run.rs index bd24dffd5..85924113c 100644 --- a/crates/attractor/src/cli/run.rs +++ b/crates/attractor/src/cli/run.rs @@ -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 = 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(), ))) } }); diff --git a/crates/attractor/src/cli/serve.rs b/crates/attractor/src/cli/serve.rs index e6865ea91..5f727e524 100644 --- a/crates/attractor/src/cli/serve.rs +++ b/crates/attractor/src/cli/serve.rs @@ -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(), ))) } }) diff --git a/crates/attractor/src/daytona_env.rs b/crates/attractor/src/daytona_env.rs new file mode 100644 index 000000000..85371697d --- /dev/null +++ b/crates/attractor/src/daytona_env.rs @@ -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, +} + +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> { + 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 { + 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, + limit: Option, + ) -> Result { + 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 { + 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, + ) -> 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}"))?; + + 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>, + _cancel_token: Option, + ) -> Result { + 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, 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, 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()); + } +} diff --git a/crates/attractor/src/lib.rs b/crates/attractor/src/lib.rs index 43dd5d41e..47cfb2519 100644 --- a/crates/attractor/src/lib.rs +++ b/crates/attractor/src/lib.rs @@ -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; diff --git a/crates/attractor/tests/daytona_integration.rs b/crates/attractor/tests/daytona_integration.rs new file mode 100644 index 000000000..4e8156edd --- /dev/null +++ b/crates/attractor/tests/daytona_integration.rs @@ -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(); +} diff --git a/crates/attractor/tests/integration.rs b/crates/attractor/tests/integration.rs index 1a0d6da55..93d030a10 100644 --- a/crates/attractor/tests/integration.rs +++ b/crates/attractor/tests/integration.rs @@ -6586,7 +6586,8 @@ async fn attractor_e2e_with_real_llm() { None, 0, &TEST_STYLES, - false, + attractor::cli::ExecutionEnvKind::Local, + Vec::new(), )) as Box) });