Add sandbox
ci/woodpecker/push/tests Pipeline was canceled

This commit is contained in:
2026-09-17 13:16:15 +00:00
parent 536c55f27b
commit 99d1c2feef
16 changed files with 1537 additions and 12 deletions
+197
View File
@@ -0,0 +1,197 @@
//! Sandboxed tool execution for the AI bot.
//!
//! A [`Sandbox`] clones a pull request into a temporary directory, builds and
//! starts its devcontainer (via `devcontainer-rs`) and exposes command
//! execution inside the resulting container. The [`tools`] module maps model
//! tool calls to commands run in that container, and [`agent`] drives the
//! tool-calling loop against OpenRouter.
pub mod agent;
pub mod tools;
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 {
/// Whether the bot should run its tools inside a sandbox container.
pub enabled: bool,
/// Container runtime binary to drive (e.g. `docker`, `podman`).
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,
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,
repo_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(repo_url, token, pull_request_number, &repo_dir).await?;
let devcontainer_path = find_devcontainer(&repo_dir)
.with_context(|| format!("no devcontainer found in `{repo_url}`"))?;
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
let container = devcontainer.up(runtime, &repo_dir).await?;
Ok(Self {
_workspace: workspace,
container,
})
}
/// 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(())
}
/// 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);
}
}