@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user