293 lines
8.7 KiB
Rust
293 lines
8.7 KiB
Rust
//! 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/<number>/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<Self> {
|
|
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<ExecOutput> {
|
|
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<PathBuf> {
|
|
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);
|
|
}
|
|
}
|