diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs index d180835be..8a4225048 100644 --- a/lib/crates/fabro-cli/src/commands/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -1066,11 +1066,17 @@ pub async fn run_command( } SandboxProvider::Daytona => { let config = daytona_config.clone().unwrap_or_default(); + let clone_params = origin_url + .as_ref() + .map(|url| fabro_daytona::GitCloneParams { + url: fabro_github::ssh_url_to_https(url), + branch: detected_base_branch.clone(), + }); let mut env = fabro_daytona::DaytonaSandbox::new( config, github_app.clone(), Some(run_id.clone()), - detected_base_branch.clone(), + clone_params, ) .await .map_err(|e| anyhow::anyhow!("{e}"))?; diff --git a/lib/crates/fabro-daytona/src/lib.rs b/lib/crates/fabro-daytona/src/lib.rs index 1f8083771..42744a1c2 100644 --- a/lib/crates/fabro-daytona/src/lib.rs +++ b/lib/crates/fabro-daytona/src/lib.rs @@ -17,6 +17,17 @@ pub use fabro_config::sandbox::{ DaytonaConfig, DaytonaNetwork, DaytonaSnapshotConfig, DockerfileSource, }; +/// Parameters for cloning a git repository into the sandbox. +/// +/// When provided to `DaytonaSandbox::new`, `initialize()` will clone the repo. +/// When `None`, the sandbox gets an empty working directory (no clone). +pub struct GitCloneParams { + /// HTTPS URL of the repository to clone. + pub url: String, + /// Optional branch to check out after cloning. + pub branch: Option, +} + /// Sandbox that runs all operations inside a Daytona cloud sandbox. pub struct DaytonaSandbox { config: DaytonaConfig, @@ -28,10 +39,9 @@ pub struct DaytonaSandbox { /// HTTPS origin URL stored after clone so we can refresh push credentials later. origin_url: tokio::sync::OnceCell, run_id: Option, - /// Explicit branch to clone. When set, overrides the branch detected by - /// `detect_repo_info` — avoids cloning a local-only worktree branch - /// (e.g. `fabro/run/...`) that was never pushed to origin. - clone_branch: Option, + /// Clone parameters. When `Some`, `initialize()` clones the repo; when + /// `None`, the sandbox gets an empty working directory. + clone_params: Option, } impl DaytonaSandbox { @@ -40,7 +50,7 @@ impl DaytonaSandbox { config: DaytonaConfig, github_app: Option, run_id: Option, - clone_branch: Option, + clone_params: Option, ) -> Result { let client = daytona_sdk::Client::new() .await @@ -54,7 +64,7 @@ impl DaytonaSandbox { event_callback: None, origin_url: tokio::sync::OnceCell::new(), run_id, - clone_branch, + clone_params, }) } @@ -81,7 +91,7 @@ impl DaytonaSandbox { event_callback: None, origin_url: tokio::sync::OnceCell::new(), run_id: None, - clone_branch: None, + clone_params: None, }) } @@ -289,8 +299,6 @@ impl DaytonaSandbox { } } -use fabro_github::ssh_url_to_https; - /// 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 @@ -389,8 +397,6 @@ impl Sandbox for DaytonaSandbox { }); let init_start = Instant::now(); - let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let params = if let Some(ref snap_cfg) = self.config.snapshot { self.emit(SandboxEvent::SnapshotEnsuring { name: snap_cfg.name.clone(), @@ -444,178 +450,171 @@ impl Sandbox for DaytonaSandbox { err })?; - // Clone the repo into the sandbox - match detect_repo_info(&cwd) { - Ok((detected_url, detected_branch)) => { - // Use explicit clone_branch if provided (avoids cloning a local-only - // worktree branch like fabro/run/... that hasn't been pushed). - let branch = self.clone_branch.clone().or(detected_branch); - // Daytona clones over HTTPS with token auth, so rewrite SSH URLs. - let url = ssh_url_to_https(&detected_url); - self.emit(SandboxEvent::GitCloneStarted { - url: url.clone(), - branch: branch.clone(), - }); - let clone_start = Instant::now(); + // Clone the repo into the sandbox (if clone_params were provided) + if let Some(ref clone_params) = self.clone_params { + let url = clone_params.url.clone(); + let branch = clone_params.branch.clone(); + self.emit(SandboxEvent::GitCloneStarted { + url: url.clone(), + branch: branch.clone(), + }); + let clone_start = Instant::now(); - // Resolve clone credentials via GitHub App or fall back to no auth - let (username, password) = match &self.github_app { - Some(creds) => { - let (owner, repo) = - fabro_github::parse_github_owner_repo(&url).map_err(|e| { - let err = format!("Failed to parse GitHub URL for clone: {e}"); - self.emit(SandboxEvent::GitCloneFailed { - url: url.clone(), - error: err.clone(), - }); - err - })?; - fabro_github::resolve_clone_credentials(creds, &owner, &repo) - .await - .map_err(|e| { - let err = - format!("Failed to get GitHub App credentials for clone: {e}"); - self.emit(SandboxEvent::GitCloneFailed { - url: url.clone(), - error: err.clone(), - }); - let duration_ms = u64::try_from(init_start.elapsed().as_millis()) - .unwrap_or(u64::MAX); - self.emit(SandboxEvent::InitializeFailed { - provider: "daytona".into(), - error: err.clone(), - duration_ms, - }); - err - })? - } - None => (None, None), - }; + // Resolve clone credentials via GitHub App or fall back to no auth + let (username, password) = match &self.github_app { + Some(creds) => { + let (owner, repo) = + fabro_github::parse_github_owner_repo(&url).map_err(|e| { + let err = format!("Failed to parse GitHub URL for clone: {e}"); + self.emit(SandboxEvent::GitCloneFailed { + url: url.clone(), + error: err.clone(), + }); + err + })?; + fabro_github::resolve_clone_credentials(creds, &owner, &repo) + .await + .map_err(|e| { + let err = + format!("Failed to get GitHub App credentials for clone: {e}"); + self.emit(SandboxEvent::GitCloneFailed { + url: url.clone(), + error: err.clone(), + }); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: "daytona".into(), + error: err.clone(), + duration_ms, + }); + err + })? + } + None => (None, None), + }; - let git_svc = sandbox - .git() - .await - .map_err(|e| format!("Failed to get Daytona git service: {e}")); - let git_svc = match git_svc { - Ok(g) => g, - Err(e) => { - self.emit(SandboxEvent::GitCloneFailed { - url: url.clone(), - error: e.clone(), - }); - let duration_ms = - u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::InitializeFailed { - provider: "daytona".into(), - error: e.clone(), - duration_ms, - }); - return Err(e); - } - }; + let git_svc = sandbox + .git() + .await + .map_err(|e| format!("Failed to get Daytona git service: {e}")); + let git_svc = match git_svc { + Ok(g) => g, + Err(e) => { + self.emit(SandboxEvent::GitCloneFailed { + url: url.clone(), + error: e.clone(), + }); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: "daytona".into(), + error: e.clone(), + duration_ms, + }); + return Err(e); + } + }; - let clone_token = password.clone(); - let clone_result = git_svc - .clone( - &url, - WORKING_DIRECTORY, - daytona_sdk::GitCloneOptions { - branch, - username, - password, - ..Default::default() - }, - ) - .await; + let clone_token = password.clone(); + let clone_result = git_svc + .clone( + &url, + WORKING_DIRECTORY, + daytona_sdk::GitCloneOptions { + branch, + username, + password, + ..Default::default() + }, + ) + .await; - match clone_result { - Ok(()) => { - let clone_duration = - u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::GitCloneCompleted { - url: url.clone(), - duration_ms: clone_duration, - }); + match clone_result { + Ok(()) => { + let clone_duration = + u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::GitCloneCompleted { + url: url.clone(), + duration_ms: clone_duration, + }); - // Store origin URL and set push credentials for later pushes - if let Some(token) = clone_token { - let _ = self.origin_url.set(url); - let process_svc = sandbox.process().await.ok(); - if let Some(ps) = process_svc { - let origin = self.origin_url.get().expect("just set"); - let auth_url = origin.replacen( - "https://", - &format!("https://x-access-token:{token}@"), - 1, - ); - let cmd = format!( - "git -c maintenance.auto=0 remote set-url origin {}", - shell_quote(&auth_url), - ); - let opts = daytona_sdk::ExecuteCommandOptions { - cwd: Some(WORKING_DIRECTORY.to_string()), - ..Default::default() - }; - let wrapped = wrap_bash_command(&cmd); - if let Ok(r) = ps.execute_command(&wrapped, opts).await { - if r.exit_code != 0 { - tracing::warn!( - exit_code = r.exit_code, - "Failed to set push credentials on origin" - ); - } + // Store origin URL and set push credentials for later pushes + if let Some(token) = clone_token { + let _ = self.origin_url.set(url); + let process_svc = sandbox.process().await.ok(); + if let Some(ps) = process_svc { + let origin = self.origin_url.get().expect("just set"); + let auth_url = origin.replacen( + "https://", + &format!("https://x-access-token:{token}@"), + 1, + ); + let cmd = format!( + "git -c maintenance.auto=0 remote set-url origin {}", + shell_quote(&auth_url), + ); + let opts = daytona_sdk::ExecuteCommandOptions { + cwd: Some(WORKING_DIRECTORY.to_string()), + ..Default::default() + }; + let wrapped = wrap_bash_command(&cmd); + if let Ok(r) = ps.execute_command(&wrapped, opts).await { + if r.exit_code != 0 { + tracing::warn!( + exit_code = r.exit_code, + "Failed to set push credentials on origin" + ); } } } } - Err(e) if self.github_app.is_none() => { - let err = format!( - "Git clone failed: {e}. If this is a private repository, \ - configure a GitHub App with `fabro install` and install it \ - for your organization." - ); - self.emit(SandboxEvent::GitCloneFailed { - url, - error: err.clone(), - }); - let duration_ms = - u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::InitializeFailed { - provider: "daytona".into(), - error: err.clone(), - duration_ms, - }); - return Err(err); - } - Err(e) => { - let err = format!("Failed to clone repo into Daytona sandbox: {e}"); - self.emit(SandboxEvent::GitCloneFailed { - url, - error: err.clone(), - }); - let duration_ms = - u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::InitializeFailed { - provider: "daytona".into(), - error: err.clone(), - duration_ms, - }); - return Err(err); - } + } + Err(e) if self.github_app.is_none() => { + let err = format!( + "Git clone failed: {e}. If this is a private repository, \ + configure a GitHub App with `fabro install` and install it \ + for your organization." + ); + self.emit(SandboxEvent::GitCloneFailed { + url, + error: err.clone(), + }); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: "daytona".into(), + error: err.clone(), + duration_ms, + }); + return Err(err); + } + Err(e) => { + let err = format!("Failed to clone repo into Daytona sandbox: {e}"); + self.emit(SandboxEvent::GitCloneFailed { + url, + error: err.clone(), + }); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: "daytona".into(), + error: err.clone(), + duration_ms, + }); + return Err(err); } } - Err(e) => { - tracing::warn!(error = %e, "Could not detect git repo for Daytona clone"); - // 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(WORKING_DIRECTORY, None) - .await - .map_err(|e| format!("Failed to create working directory: {e}"))?; - } + } else { + // No clone params — create working directory without a repo + let fs_svc = sandbox + .fs() + .await + .map_err(|e| format!("Failed to get Daytona fs service: {e}"))?; + fs_svc + .create_folder(WORKING_DIRECTORY, None) + .await + .map_err(|e| format!("Failed to create working directory: {e}"))?; } let sandbox_name = sandbox.name.clone(); diff --git a/lib/crates/fabro-workflows/tests/daytona_integration.rs b/lib/crates/fabro-workflows/tests/daytona_integration.rs index 92cbe424b..285d9b423 100644 --- a/lib/crates/fabro-workflows/tests/daytona_integration.rs +++ b/lib/crates/fabro-workflows/tests/daytona_integration.rs @@ -34,7 +34,14 @@ async fn create_env_with_github_app( if let Some(home) = dirs::home_dir() { dotenvy::from_path(home.join(".fabro/.env")).ok(); } - DaytonaSandbox::new(DaytonaConfig::default(), github_app, None, None) + let cwd = std::env::current_dir().unwrap(); + let clone_params = fabro_daytona::detect_repo_info(&cwd) + .ok() + .map(|(url, branch)| fabro_daytona::GitCloneParams { + url: fabro_github::ssh_url_to_https(&url), + branch, + }); + DaytonaSandbox::new(DaytonaConfig::default(), github_app, None, clone_params) .await .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?") } @@ -1852,9 +1859,6 @@ async fn daytona_cp_upload_download_round_trip() { async fn daytona_computer_use_browser_screenshot() { use base64::Engine; - // Run from a temp dir so detect_repo_info() finds no git repo and skips cloning. - let tmp = tempfile::tempdir().unwrap(); - std::env::set_current_dir(tmp.path()).unwrap(); dotenvy::dotenv().ok(); if let Some(home) = dirs::home_dir() { dotenvy::from_path(home.join(".fabro/.env")).ok(); @@ -2013,8 +2017,6 @@ async fn daytona_playwright_mcp_sandbox_transport() { use fabro_agent::Sandbox; // Create sandbox from daytona-medium (has Node.js + Chromium) - let tmp = tempfile::tempdir().unwrap(); - std::env::set_current_dir(tmp.path()).unwrap(); dotenvy::dotenv().ok(); if let Some(home) = dirs::home_dir() { dotenvy::from_path(home.join(".fabro/.env")).ok();