replace ContainerRuntime CLI to Bollard crate (default: docker socket)
ci/woodpecker/push/tests Pipeline was successful

limit tool result to open router tool response (with truncated info for
ai)

Hard kill if graceful shutdown is too long
This commit is contained in:
2026-09-17 19:51:59 +00:00
parent 78ad2bf701
commit 624bc1e028
16 changed files with 1327 additions and 354 deletions
+101 -3
View File
@@ -25,7 +25,7 @@ const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devc
/// Sandbox-related runtime configuration.
#[derive(Clone)]
pub struct SandboxConfig {
/// Container runtime binary to drive (e.g. `docker`, `podman`).
/// 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,
@@ -35,6 +35,8 @@ pub struct SandboxConfig {
pub struct Sandbox {
// Owns the temporary directory; dropping it cleans up the clone.
_workspace: TempDir,
/// Path of the clone, as bind-mounted into the container.
repo_dir: PathBuf,
container: Container,
}
@@ -54,6 +56,7 @@ impl Sandbox {
let repo_dir = workspace.path().join("repo");
clone_pull_request(repo_url, token, pull_request_number, &repo_dir).await?;
make_readable(&repo_dir).await?;
let devcontainer_path = find_devcontainer(&repo_dir)
.with_context(|| format!("no devcontainer found in `{repo_url}`"))?;
@@ -63,10 +66,79 @@ impl Sandbox {
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
let container = devcontainer.up(runtime, &repo_dir).await?;
Ok(Self {
let sandbox = Self {
_workspace: workspace,
repo_dir,
container,
})
};
sandbox.check_workspace().await?;
Ok(sandbox)
}
/// Vérifie que le clone est bien visible dans le container.
///
/// Sans ce contrôle, un montage vide — le daemon ne voit pas le clone, par
/// exemple quand Herald est dans un container sur une autre machine — 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).
@@ -141,6 +213,32 @@ async fn clone_pull_request(
Ok(())
}
/// Rend le clone lisible par les utilisateurs du container sandbox.
///
/// Le container peut ne pas avoir les mêmes uid que Herald (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 Herald 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<()> {