replace ContainerRuntime CLI to Bollard crate (default: docker socket)
ci/woodpecker/push/tests Pipeline was successful
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:
@@ -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<()> {
|
||||
|
||||
Reference in New Issue
Block a user