//! Le [`Sandbox`] : clone d'une pull request exécuté dans un devcontainer //! éphémère, et sa configuration. use std::{ path::{Path, PathBuf}, process::Stdio, }; use anyhow::Context; use devcontainer_rs::{Container, ContainerRuntime, ExecOutput}; use tempfile::TempDir; use tracing::{info, instrument}; /// Devcontainer locations recognized within a repository, in priority order. const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"]; /// Sandbox-related runtime configuration. #[derive(Clone)] pub struct SandboxConfig { /// Client du daemon de containers qui exécute la sandbox. pub runtime: ContainerRuntime, /// Maximum number of tool-calling iterations per agent run. pub max_iterations: usize, } /// A cloned repository running inside an ephemeral devcontainer. pub struct Sandbox { // Owns the temporary directory; dropping it cleans up the clone. _workspace: TempDir, /// Path of the clone, as copied into the container. repo_dir: PathBuf, container: Container, } impl Sandbox { /// Clones the pull request head, builds the devcontainer and starts it. /// /// The clone is PR-aware: it fetches `refs/pull//head`, which works /// for both same-repository and forked pull requests. #[instrument(skip(runtime, token), fields(pr = pull_request_number))] pub async fn create( runtime: &ContainerRuntime, clone_url: &str, token: &str, pull_request_number: u64, ) -> anyhow::Result { let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?; let repo_dir = workspace.path().join("repo"); clone_pull_request(clone_url, token, pull_request_number, &repo_dir).await?; make_readable(&repo_dir).await?; let devcontainer = match find_devcontainer(&repo_dir) { Some(path) => devcontainer_rs::parse(&path).await?, None => { info!( "no devcontainer found in the pull request, falling back to the default image" ); devcontainer_rs::DevContainer::default() } }; let container = devcontainer.up(runtime, &repo_dir).await?; let sandbox = Self { _workspace: workspace, repo_dir, container, }; if let Err(err) = sandbox.check_workspace().await { let _ = sandbox.container.remove().await; return Err(err); } Ok(sandbox) } /// Vérifie que le clone est bien visible dans le container. /// /// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu /// recevoir le clone — fait échouer chaque outil ; le modèle enchaîne alors les /// appels ratés jusqu'au budget d'itérations, sans jamais pouvoir reviewer quoi /// que ce soit. async fn check_workspace(&self) -> anyhow::Result<()> { let workspace_folder = self.workspace_folder(); let probe = self .container .exec(&["ls", "-A", "--", workspace_folder]) .await .with_context(|| format!("failed to list `{workspace_folder}` in the sandbox"))?; if !probe.success() { let details = self.diagnose_workspace().await; anyhow::bail!( "the sandbox cannot list `{workspace_folder}`: {} ({details})", probe.stderr.trim() ); } if probe.stdout.trim().is_empty() { anyhow::bail!( "the sandbox workspace `{workspace_folder}` is empty: the container daemon does not see the clone" ); } info!( clone = %self.repo_dir.display(), workspace = %workspace_folder, entries = probe.stdout.lines().count(), "Sandbox workspace is readable" ); Ok(()) } /// Rassemble de quoi expliquer un refus d'accès au workspace. /// /// Un `EACCES` a deux causes possibles, indistinguables dans le message de /// `ls` : les permissions du clone, ou un confinement du noyau qui bloque /// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de /// montage permettent de trancher. async fn diagnose_workspace(&self) -> String { let mut details = Vec::new(); if let Ok(output) = self.container.exec(&["id"]).await { details.push(output.stdout.trim().to_string()); } if let Ok(output) = self .container .exec(&["ls", "-ld", "--", self.workspace_folder()]) .await && output.success() { details.push(output.stdout.trim().to_string()); } details.join(", ") } /// Executes a command in the container as an argv vector (no shell). pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result { Ok(self.container.exec(cmd).await?) } /// Path of the repository inside the container. pub fn workspace_folder(&self) -> &str { self.container.workspace_folder() } /// Stops and removes the container. The temporary clone is removed on drop. pub async fn cleanup(self) -> anyhow::Result<()> { self.container.remove().await?; Ok(()) } } fn find_devcontainer(repo_dir: &Path) -> Option { DEVCONTAINER_PATHS .iter() .map(|relative| repo_dir.join(relative)) .find(|candidate| candidate.is_file()) } async fn clone_pull_request( repo_url: &str, token: &str, pull_request_number: u64, dest: &Path, ) -> anyhow::Result<()> { let dest = dest.display().to_string(); run_git( token, &[ "clone".to_string(), "--depth".to_string(), "1".to_string(), repo_url.to_string(), dest.clone(), ], ) .await?; run_git( token, &[ "-C".to_string(), dest.clone(), "fetch".to_string(), "--depth".to_string(), "1".to_string(), "origin".to_string(), format!("refs/pull/{pull_request_number}/head"), ], ) .await?; run_git( token, &[ "-C".to_string(), dest, "checkout".to_string(), "FETCH_HEAD".to_string(), ], ) .await?; Ok(()) } /// Rend le clone lisible par les utilisateurs du container sandbox. /// /// Le container peut ne pas avoir les mêmes uid que Steep (podman rootless /// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être /// restrictif : sans cela, les outils de la sandbox échouent en `Permission /// denied` sur des fichiers que Steep vient de cloner lui-même. async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> { let output = tokio::process::Command::new("chmod") .args(["-R", "a+rX"]) .arg(repo_dir) .stdin(Stdio::null()) .output() .await .context("failed to spawn chmod")?; if !output.status.success() { anyhow::bail!( "chmod failed on `{}`: {}", repo_dir.display(), String::from_utf8_lossy(&output.stderr).trim() ); } Ok(()) } /// Runs git with the token injected through `http.extraHeader`, keeping the /// secret out of the process arguments. async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> { let output = tokio::process::Command::new("git") .args(args) .env("GIT_CONFIG_COUNT", "1") .env("GIT_CONFIG_KEY_0", "http.extraHeader") .env( "GIT_CONFIG_VALUE_0", format!("Authorization: token {token}"), ) .env("GIT_TERMINAL_PROMPT", "0") .stdin(Stdio::null()) .output() .await .context("failed to spawn git")?; if !output.status.success() { anyhow::bail!( "git {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr).trim() ); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn find_devcontainer_prefers_dot_devcontainer_dir() { let dir = tempfile::tempdir().unwrap(); let nested = dir.path().join(".devcontainer"); std::fs::create_dir(&nested).unwrap(); std::fs::write(nested.join("devcontainer.json"), "{}").unwrap(); std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap(); assert_eq!( find_devcontainer(dir.path()), Some(nested.join("devcontainer.json")) ); } #[test] fn find_devcontainer_returns_none_when_absent() { let dir = tempfile::tempdir().unwrap(); assert_eq!(find_devcontainer(dir.path()), None); } }