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:
+1
-1
@@ -20,5 +20,5 @@ RUST_BACKTRACE=1
|
||||
METRICS_BIND_ADDR=
|
||||
|
||||
# Sandboxed tool execution
|
||||
CONTAINER_RUNTIME=docker
|
||||
# DOCKER_HOST=
|
||||
SANDBOX_MAX_ITERATIONS=8
|
||||
|
||||
Generated
+133
-3
@@ -295,6 +295,49 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bollard"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bollard-stubs",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"http 1.5.0",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-named-pipe",
|
||||
"hyper-util",
|
||||
"hyperlocal",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"url",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bollard-stubs"
|
||||
version = "1.53.1-rc.29.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -432,11 +475,16 @@ name = "devcontainer-rs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bollard",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -488,7 +536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -508,6 +556,16 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
@@ -830,6 +888,20 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-named-pipe"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
@@ -869,6 +941,21 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperlocal"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
@@ -1777,7 +1864,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2066,6 +2153,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -2179,6 +2277,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -2189,7 +2298,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2299,6 +2408,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -2796,6 +2916,16 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -8,6 +8,7 @@ resolver = "3"
|
||||
[workspace.dependencies]
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls", "stream"] }
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = "0.7"
|
||||
futures-util = "0.3"
|
||||
serde_json = "1.0"
|
||||
@@ -24,6 +25,7 @@ anyhow = { version = "1", features = ["backtrace"] }
|
||||
thiserror = "2.0"
|
||||
ring = "0.17"
|
||||
hex = "0.4"
|
||||
bytes = "1.1"
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
||||
|
||||
|
||||
+30
-3
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.97-trixie as builder
|
||||
FROM rust:1.98-trixie as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -10,6 +10,33 @@ RUN cargo build --release --package herald-server
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
||||
# git clones the pull request. ca-certificates is what every HTTPS call needs
|
||||
# (Gitea, OpenRouter, git). The shared libraries are the non-base ones the binary
|
||||
# links against, as reported by `ldd target/release/herald-server`; libc, libm
|
||||
# and libgcc_s come from the base image.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
libssl3t64 \
|
||||
libzstd1 \
|
||||
zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Herald drives the container daemon through its socket (DOCKER_HOST, default
|
||||
# unix:///var/run/docker.sock), so neither docker nor podman is needed here: the
|
||||
# compose file mounts the socket. Reaching that socket is what this user needs,
|
||||
# and the socket is root-equivalent, so either run the container as root or give
|
||||
# it the socket's group, e.g. `group_add: ["<gid of the host docker group>"]`.
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin --uid 10001 herald
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/herald-server .
|
||||
CMD [ "./herald-server" ]
|
||||
COPY --from=builder /app/target/release/herald-server ./herald-server
|
||||
|
||||
# git looks for its configuration under $HOME.
|
||||
ENV HOME=/home/herald
|
||||
|
||||
USER herald
|
||||
|
||||
# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut
|
||||
# down gracefully.
|
||||
CMD ["./herald-server"]
|
||||
|
||||
@@ -37,8 +37,8 @@ Herald reads its configuration from environment variables (a `.env` file is supp
|
||||
| `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. |
|
||||
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
|
||||
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
|
||||
| `CONTAINER_RUNTIME` | *(optional)* Container runtime binary used for the sandbox (`docker` or `podman`). Defaults to `docker` |
|
||||
| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` |
|
||||
| `DOCKER_HOST` | *(optional)* Container daemon socket Herald drives, e.g. `unix:///run/user/1000/podman/podman.sock` for a rootless podman. Defaults to `unix:///var/run/docker.sock` |
|
||||
|
||||
## Sandboxed reviews
|
||||
|
||||
@@ -56,8 +56,16 @@ Herald reviews pull requests inside an ephemeral
|
||||
4. posts the review, anchoring each comment on the added or removed line it
|
||||
refers to, and removes the container and the temporary clone.
|
||||
|
||||
The container runtime is selected with `CONTAINER_RUNTIME` (`docker` or
|
||||
`podman`). The repository must contain a `.devcontainer/devcontainer.json`.
|
||||
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
|
||||
`unix:///var/run/docker.sock`), which covers both docker and podman's
|
||||
Docker-compatible socket. The repository must contain a
|
||||
`.devcontainer/devcontainer.json`.
|
||||
|
||||
The `runArgs` of that file are read but deliberately **not** passed to the daemon:
|
||||
they come from an untrusted pull request, and one of them (`--network host`) would
|
||||
attach the container to another network and quietly
|
||||
defeat the network cut described below. A devcontainer that relies on them
|
||||
(`--gpus all`, `--cap-add`, `--shm-size`…) will not get them.
|
||||
|
||||
Each sandbox is isolated: it gets its own image tag, container and network. The
|
||||
container starts with network access so the `postCreateCommand` /
|
||||
@@ -70,7 +78,7 @@ network and image are removed when the review ends (including on failure).
|
||||
|
||||
The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension).
|
||||
|
||||
Open the project and reopen it in the container — the Rust toolchain is pre-installed.
|
||||
Open the project and reopen it in the container — the Rust toolchain is pre-installed, along with rootless podman, so sandboxed reviews can be exercised locally: start its API socket with `podman system service --time=0 &` and set `DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock`.
|
||||
|
||||
**Without Dev Container**, you just need a Rust toolchain:
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bollard = "0.21"
|
||||
bytes = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
tar = "0.4"
|
||||
tokio = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,12 @@ pub struct DevContainerSchema {
|
||||
#[serde(rename = "remoteUser", default)]
|
||||
pub remote_user: Option<String>,
|
||||
|
||||
/// Arguments passés à `docker run`.
|
||||
///
|
||||
/// Lus pour rester fidèle au format `devcontainer.json`, mais
|
||||
/// **délibérément pas transmis** au runtime : ils viennent d'une pull
|
||||
/// request non fiable et pourraient casser l'isolation de la sandbox (voir
|
||||
/// [`DevContainer::run_args`]).
|
||||
#[serde(rename = "runArgs", default)]
|
||||
pub run_args: Vec<String>,
|
||||
}
|
||||
@@ -52,7 +58,6 @@ pub struct DevContainer {
|
||||
pub post_create_command: Option<String>,
|
||||
pub post_start_command: Option<String>,
|
||||
pub remote_user: Option<String>,
|
||||
pub run_args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -114,7 +119,6 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
||||
post_create_command: schema.post_create_command,
|
||||
post_start_command: schema.post_start_command,
|
||||
remote_user: schema.remote_user,
|
||||
run_args: schema.run_args,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -203,8 +207,7 @@ mod tests {
|
||||
"containerEnv": {
|
||||
"RUST_LOG": "debug"
|
||||
},
|
||||
"remoteUser": "dev",
|
||||
"runArgs": ["--userns=keep-id"]
|
||||
"remoteUser": "dev"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -217,7 +220,6 @@ mod tests {
|
||||
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
|
||||
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
||||
assert_eq!(config.remote_user.as_deref(), Some("dev"));
|
||||
assert_eq!(config.run_args, vec!["--userns=keep-id"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use crate::{
|
||||
@@ -7,6 +9,7 @@ use crate::{
|
||||
metrics,
|
||||
open_router::{OpenRouterClient, Tool},
|
||||
sandbox::{Sandbox, SandboxConfig, agent},
|
||||
text::excerpt,
|
||||
};
|
||||
|
||||
#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))]
|
||||
@@ -59,7 +62,7 @@ pub async fn exec_review(
|
||||
.replace("{comment}", &review_payload.comment.body)
|
||||
.replace("{changes}", &changes);
|
||||
|
||||
let (message, cost) = run_sandboxed_review(
|
||||
let (mut review_result, cost) = run_sandboxed_review(
|
||||
gitea_api,
|
||||
open_router_client,
|
||||
sandbox_config,
|
||||
@@ -69,7 +72,6 @@ pub async fn exec_review(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut review_result = serde_json::from_str::<ReviewResult>(&message)?;
|
||||
resolve_review_sides(&mut review_result, &changed_lines);
|
||||
|
||||
review_result.cost = cost;
|
||||
@@ -112,6 +114,10 @@ pub async fn exec_review(
|
||||
|
||||
/// Runs the review inside a sandbox container, letting the model explore the
|
||||
/// repository with tools before answering.
|
||||
///
|
||||
/// The answer of the model is parsed by [`ReviewResult::from_str`], which the
|
||||
/// agent loop enforces: an answer that is not a review is sent back to the model
|
||||
/// for correction.
|
||||
async fn run_sandboxed_review(
|
||||
gitea_api: &GiteaAPI,
|
||||
open_router_client: &OpenRouterClient,
|
||||
@@ -119,7 +125,7 @@ async fn run_sandboxed_review(
|
||||
tools: Vec<Tool>,
|
||||
review_payload: &ReviewPayload,
|
||||
bot_request: &str,
|
||||
) -> anyhow::Result<(String, Option<f64>)> {
|
||||
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
|
||||
let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
|
||||
|
||||
let sandbox = Sandbox::create(
|
||||
@@ -151,7 +157,7 @@ async fn run_sandboxed_review(
|
||||
"Sandboxed review finished"
|
||||
);
|
||||
|
||||
Ok((result.message, result.cost))
|
||||
Ok((result.answer, result.cost))
|
||||
}
|
||||
|
||||
fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
||||
@@ -452,6 +458,61 @@ fn format_line_numbers(lines: &[u64]) -> String {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/// Number of characters of a model answer kept in the logs when it cannot be
|
||||
/// parsed.
|
||||
const MAX_LOGGED_ANSWER: usize = 500;
|
||||
|
||||
impl FromStr for ReviewResult {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
/// Parses the review the model answered with.
|
||||
///
|
||||
/// This is the contract the agent loop enforces: a rejected answer is sent
|
||||
/// back to the model, with the reason, so that it can correct itself.
|
||||
///
|
||||
/// The contract is a raw JSON object, but models sometimes wrap it in a
|
||||
/// markdown code fence or surround it with a sentence: the object is then
|
||||
/// extracted from the answer before failing, and the answer is logged so a
|
||||
/// breach of the contract can be diagnosed.
|
||||
fn from_str(message: &str) -> Result<Self, Self::Err> {
|
||||
let error = match serde_json::from_str::<Self>(message) {
|
||||
Ok(review_result) => return Ok(review_result),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
// A markdown code fence or a sentence around the object is tolerated, with
|
||||
// a warning: the contract asks for a raw JSON object.
|
||||
if let Some(json) = json_object(message)
|
||||
&& let Ok(review_result) = serde_json::from_str::<Self>(json)
|
||||
{
|
||||
warn!(
|
||||
"Model answer is not a raw JSON object, it was extracted from the surrounding text"
|
||||
);
|
||||
|
||||
return Ok(review_result);
|
||||
}
|
||||
|
||||
// The reason of the rejection is logged along with the answer: without it,
|
||||
// a broken answer is impossible to diagnose.
|
||||
warn!(
|
||||
answer = %excerpt(message, MAX_LOGGED_ANSWER),
|
||||
reason = %error,
|
||||
"Model answer is not the expected JSON"
|
||||
);
|
||||
|
||||
anyhow::bail!("the answer is not valid JSON: {error}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the outermost `{...}` of an answer, which ignores a markdown code
|
||||
/// fence or any text around it.
|
||||
fn json_object(message: &str) -> Option<&str> {
|
||||
let start = message.find('{')?;
|
||||
let end = message.rfind('}')?;
|
||||
|
||||
(start < end).then(|| &message[start..=end])
|
||||
}
|
||||
|
||||
/// Resolves the side each review is anchored on and drops the reviews that do
|
||||
/// not match a line the pull request changes.
|
||||
///
|
||||
@@ -824,6 +885,45 @@ mod tests {
|
||||
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_answer_is_parsed() {
|
||||
let answer = r#"{"reviews": [], "comment": "ok"}"#;
|
||||
|
||||
assert_eq!(answer.parse::<ReviewResult>().unwrap().comment, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fenced_answer_is_extracted() {
|
||||
let answer = concat!(
|
||||
"Voici ma review :\n",
|
||||
"```json\n",
|
||||
"{\"reviews\": [], \"comment\": \"rien à signaler\"}\n",
|
||||
"```\n"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
answer.parse::<ReviewResult>().unwrap().comment,
|
||||
"rien à signaler"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_around_the_object_is_ignored() {
|
||||
let answer = r#"Rien à signaler. {"reviews": [], "comment": "ok"} Bonne journée !"#;
|
||||
|
||||
assert_eq!(answer.parse::<ReviewResult>().unwrap().comment, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_without_json_is_rejected() {
|
||||
assert!("Je n'ai rien relevé.".parse::<ReviewResult>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_that_is_an_object_but_not_a_review_is_rejected() {
|
||||
assert!(r#"{"message": "LGTM"}"#.parse::<ReviewResult>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_sides_from_the_model_are_tolerated() {
|
||||
let changed_lines = parse_changed_lines(DIFF);
|
||||
|
||||
@@ -15,7 +15,8 @@ pub const SANDBOX_SYSTEM_PROMPT: &str = "
|
||||
tools (ls, read_file, grep, find) to explore the code and gather the context
|
||||
you need before answering. Paths are relative to the repository root.
|
||||
|
||||
When you have enough information, answer with the requested JSON only.
|
||||
When you have enough information, answer with the requested JSON only, as a raw
|
||||
JSON object: no markdown code fence, nothing before or after it.
|
||||
";
|
||||
|
||||
pub const REVIEW_PROMPT: &str = "
|
||||
@@ -39,6 +40,8 @@ pub const REVIEW_PROMPT: &str = "
|
||||
|
||||
Return your feedback, in french, with only this json format, reviews must contain each review
|
||||
All fields are mandatory.
|
||||
Answer with the raw json object only: no markdown code fence, no text before or
|
||||
after it.
|
||||
(filename field must contain the full path with extension; line must be one of the
|
||||
listed line numbers for that file, and side must be \"added\" when the line comes
|
||||
from the `added` list or \"removed\" when it comes from the `removed` list)
|
||||
|
||||
@@ -12,7 +12,6 @@ pub struct EnvConfig {
|
||||
pub gitea_token: String,
|
||||
pub gitea_timeout: u64,
|
||||
pub metrics_bind_addr: Option<String>,
|
||||
pub container_runtime: String,
|
||||
pub sandbox_max_iterations: usize,
|
||||
}
|
||||
|
||||
@@ -27,8 +26,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
||||
let gitea_token = try_get_env("GITEA_TOKEN")?;
|
||||
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
||||
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
||||
let container_runtime =
|
||||
std::env::var("CONTAINER_RUNTIME").unwrap_or_else(|_| "docker".to_string());
|
||||
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
@@ -45,7 +42,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
||||
gitea_token,
|
||||
gitea_timeout,
|
||||
metrics_bind_addr,
|
||||
container_runtime,
|
||||
sandbox_max_iterations,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
bot::Bot,
|
||||
gitea::{GiteaAPI, WebhookType},
|
||||
@@ -23,6 +25,13 @@ mod metrics;
|
||||
mod open_router;
|
||||
mod sandbox;
|
||||
mod state;
|
||||
mod text;
|
||||
|
||||
/// Délai laissé aux reviews en cours et aux serveurs pour s'arrêter proprement.
|
||||
///
|
||||
/// Sans lui, une review prise dans une sandbox récalcitrante garderait le processus
|
||||
/// en vie jusqu'à ce que le superviseur le tue.
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
dotenv().ok();
|
||||
@@ -79,14 +88,14 @@ async fn run() -> anyhow::Result<()> {
|
||||
let shutdown = CancellationToken::new();
|
||||
|
||||
let sandbox = SandboxConfig {
|
||||
runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()),
|
||||
runtime: devcontainer_rs::ContainerRuntime::connect()?,
|
||||
max_iterations: config.sandbox_max_iterations,
|
||||
};
|
||||
|
||||
if !sandbox.runtime.available().await {
|
||||
warn!(
|
||||
runtime = sandbox.runtime.program(),
|
||||
"Container runtime is not available, every review will fail"
|
||||
endpoint = sandbox.runtime.endpoint(),
|
||||
"Container daemon is not reachable, every review will fail"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,11 +128,28 @@ async fn run() -> anyhow::Result<()> {
|
||||
anyhow::Ok(())
|
||||
};
|
||||
|
||||
tokio::try_join!(
|
||||
bot.start(rx, shutdown.clone()),
|
||||
api::start(app_state, shutdown.clone()),
|
||||
signal
|
||||
)?;
|
||||
let shutdown_deadline = async {
|
||||
shutdown.cancelled().await;
|
||||
tokio::time::sleep(SHUTDOWN_TIMEOUT).await;
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
result = async {
|
||||
tokio::try_join!(
|
||||
bot.start(rx, shutdown.clone()),
|
||||
api::start(app_state, shutdown.clone()),
|
||||
signal
|
||||
)
|
||||
} => {
|
||||
result?;
|
||||
}
|
||||
() = shutdown_deadline => {
|
||||
warn!(
|
||||
timeout = ?SHUTDOWN_TIMEOUT,
|
||||
"Shutdown did not finish in time, exiting anyway"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Shutdown complete");
|
||||
|
||||
|
||||
@@ -17,8 +17,24 @@ use tracing::instrument;
|
||||
/// OpenRouter API root, version prefix included.
|
||||
const BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||
|
||||
/// The model decides on its own which tool to call.
|
||||
const TOOL_CHOICE_AUTO: &str = "auto";
|
||||
/// What the model is allowed to do on a given turn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolChoice {
|
||||
/// The model decides whether to call a tool.
|
||||
Auto,
|
||||
/// The model must answer, without calling any tool. The tools stay declared,
|
||||
/// so the conversation remains the same shape as on the other turns.
|
||||
None,
|
||||
}
|
||||
|
||||
impl ToolChoice {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a completion that may contain tool calls.
|
||||
pub struct ToolChatResult {
|
||||
@@ -250,13 +266,18 @@ impl OpenRouterClient {
|
||||
|
||||
/// Sends a conversation with tool definitions and returns either a final
|
||||
/// message or the tool calls requested by the model.
|
||||
///
|
||||
/// The conversation is borrowed rather than taken by value: the caller keeps
|
||||
/// appending to it between iterations, and a copy per iteration would be pure
|
||||
/// waste.
|
||||
#[instrument(skip(self, messages, tools), err)]
|
||||
pub async fn chat_with_tools(
|
||||
&self,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<Tool>,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
tool_choice: ToolChoice,
|
||||
) -> anyhow::Result<ToolChatResult> {
|
||||
let response = self.complete(&messages, &tools).await?;
|
||||
let response = self.complete(messages, tools, tool_choice).await?;
|
||||
|
||||
let cost = response.usage.and_then(|usage| usage.cost);
|
||||
let message = response
|
||||
@@ -273,13 +294,18 @@ impl OpenRouterClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete(&self, messages: &[Message], tools: &[Tool]) -> anyhow::Result<ChatResponse> {
|
||||
async fn complete(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
tool_choice: ToolChoice,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let request = ChatRequest {
|
||||
model: &self.model,
|
||||
messages,
|
||||
reasoning: Reasoning { enabled: true },
|
||||
tools,
|
||||
tool_choice: TOOL_CHOICE_AUTO,
|
||||
tool_choice: tool_choice.as_str(),
|
||||
};
|
||||
|
||||
let response = self
|
||||
@@ -364,7 +390,7 @@ mod tests {
|
||||
messages: &messages,
|
||||
reasoning: Reasoning { enabled: true },
|
||||
tools: &tools,
|
||||
tool_choice: TOOL_CHOICE_AUTO,
|
||||
tool_choice: ToolChoice::Auto.as_str(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -375,6 +401,12 @@ mod tests {
|
||||
assert_eq!(request["messages"][0]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forced_answer_turn_disables_the_tools() {
|
||||
assert_eq!(ToolChoice::None.as_str(), "none");
|
||||
assert_eq!(ToolChoice::Auto.as_str(), "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parses_tool_calls_and_cost() {
|
||||
let response: ChatResponse = serde_json::from_value(json!({
|
||||
|
||||
@@ -5,18 +5,58 @@
|
||||
//! back, until the model produces a final message or the iteration budget is
|
||||
//! exhausted.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall},
|
||||
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice},
|
||||
sandbox::{Sandbox, tools},
|
||||
text::{excerpt, truncate_bytes},
|
||||
};
|
||||
|
||||
/// Number of characters of the arguments of a tool call kept in the logs.
|
||||
const MAX_LOGGED_ARGUMENTS: usize = 200;
|
||||
|
||||
/// Number of characters of a tool result kept in the logs.
|
||||
///
|
||||
/// Sizes alone make a broken tool invisible: a command that fails returns a short
|
||||
/// `error: …` instead of the expected content, which is exactly what this excerpt
|
||||
/// makes obvious.
|
||||
const MAX_LOGGED_OUTPUT: usize = 120;
|
||||
|
||||
/// Maximum number of bytes of a tool result handed to the model.
|
||||
///
|
||||
/// A result stays in the conversation and is re-sent on every following
|
||||
/// iteration, so an unbounded one (a whole file, a match on every line) inflates
|
||||
/// the context and the cost of the whole rest of the run — and can overflow the
|
||||
/// model context window outright.
|
||||
const MAX_TOOL_OUTPUT_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// Asked of the model when its answer does not satisfy the caller's contract.
|
||||
const RETRY_PROMPT: &str = "
|
||||
Your answer is not valid: {error}
|
||||
|
||||
Answer again with the requested format only: a raw json object, without any
|
||||
markdown code fence and without any text before or after it.
|
||||
";
|
||||
|
||||
/// Asked of the model on the last turn, in place of one more exploration turn.
|
||||
///
|
||||
/// Without it, a model still exploring on its last turn would ask for another
|
||||
/// tool, and the run would end on the iteration budget with no answer at all.
|
||||
const FINAL_PROMPT: &str = "
|
||||
You are out of turns: this is your last one. Answer now, with the requested
|
||||
format, from what you have already gathered, without calling any tool.
|
||||
";
|
||||
|
||||
/// Final output of an agent run.
|
||||
pub struct AgentResult {
|
||||
pub message: String,
|
||||
pub struct AgentResult<A> {
|
||||
/// Answer of the model, parsed into the type the caller asked for.
|
||||
pub answer: A,
|
||||
pub cost: Option<f64>,
|
||||
pub iterations: usize,
|
||||
}
|
||||
@@ -26,25 +66,64 @@ pub struct AgentResult {
|
||||
///
|
||||
/// `tool_definitions` is the set of tools the model may call; it is selected by
|
||||
/// the caller based on the webhook action (see [`tools::for_webhook`]).
|
||||
pub async fn run(
|
||||
///
|
||||
/// The answer of the model is parsed into `A`, which is how the caller states the
|
||||
/// format it expects: while [`FromStr`] rejects the answer, the error is sent back
|
||||
/// to the model so that it can correct itself, which costs an iteration. The
|
||||
/// parsing happens here because that is where the conversation and the remaining
|
||||
/// budget are at hand.
|
||||
pub async fn run<A>(
|
||||
open_router: &OpenRouterClient,
|
||||
sandbox: &Sandbox,
|
||||
tool_definitions: Vec<Tool>,
|
||||
system_prompt: &str,
|
||||
user_prompt: &str,
|
||||
max_iterations: usize,
|
||||
) -> anyhow::Result<AgentResult> {
|
||||
) -> anyhow::Result<AgentResult<A>>
|
||||
where
|
||||
A: FromStr,
|
||||
A::Err: std::fmt::Display,
|
||||
{
|
||||
let mut messages = vec![
|
||||
Message::new(Role::System, system_prompt),
|
||||
Message::new(Role::User, user_prompt),
|
||||
];
|
||||
|
||||
info!(
|
||||
tools = %tool_definitions
|
||||
.iter()
|
||||
.map(|tool| tool.function.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
max_iterations,
|
||||
"Starting tool-calling loop"
|
||||
);
|
||||
|
||||
let mut total_cost = 0.0_f64;
|
||||
let mut has_cost = false;
|
||||
let mut last_rejection: Option<String> = None;
|
||||
|
||||
for iteration in 1..=max_iterations {
|
||||
// Le dernier tour n'est plus un tour d'exploration : le modèle doit rendre
|
||||
// sa réponse, avec ce qu'il a déjà vu.
|
||||
let last = iteration == max_iterations;
|
||||
|
||||
if last {
|
||||
info!(iteration, "Last turn: asking for the final answer");
|
||||
messages.push(Message::new(Role::User, FINAL_PROMPT));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let response = open_router
|
||||
.chat_with_tools(messages.clone(), tool_definitions.clone())
|
||||
.chat_with_tools(
|
||||
&messages,
|
||||
&tool_definitions,
|
||||
if last {
|
||||
ToolChoice::None
|
||||
} else {
|
||||
ToolChoice::Auto
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(cost) = response.cost {
|
||||
@@ -52,12 +131,44 @@ pub async fn run(
|
||||
has_cost = true;
|
||||
}
|
||||
|
||||
if response.tool_calls.is_empty() {
|
||||
return Ok(AgentResult {
|
||||
message: response.message.unwrap_or_default(),
|
||||
cost: has_cost.then_some(total_cost),
|
||||
iterations: iteration,
|
||||
});
|
||||
info!(
|
||||
iteration,
|
||||
max_iterations,
|
||||
last_turn = last,
|
||||
tool_calls = response.tool_calls.len(),
|
||||
answer_bytes = response.message.as_deref().map_or(0, str::len),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"Model answered"
|
||||
);
|
||||
|
||||
// Au dernier tour, les outils sont refusés : même si le fournisseur les
|
||||
// renvoie malgré tout, on ne les exécute pas et on tente la réponse.
|
||||
if last || response.tool_calls.is_empty() {
|
||||
let answer = response.message.unwrap_or_default();
|
||||
|
||||
match answer.parse::<A>() {
|
||||
Ok(parsed) => {
|
||||
return Ok(AgentResult {
|
||||
answer: parsed,
|
||||
cost: has_cost.then_some(total_cost),
|
||||
iterations: iteration,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(iteration, %err, "Model answer was rejected, asking for another one");
|
||||
|
||||
// Keep the rejected answer in the history, so that the model
|
||||
// sees what it has to fix.
|
||||
messages.push(Message::new(Role::Assistant, answer));
|
||||
messages.push(Message::new(
|
||||
Role::User,
|
||||
RETRY_PROMPT.replace("{error}", &err.to_string()),
|
||||
));
|
||||
|
||||
last_rejection = Some(err.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(Message::assistant_with_tool_calls(
|
||||
@@ -66,28 +177,71 @@ pub async fn run(
|
||||
));
|
||||
|
||||
for call in &response.tool_calls {
|
||||
debug!(tool = call.name(), "Executing tool call");
|
||||
let content = execute(sandbox, call).await;
|
||||
let started = Instant::now();
|
||||
let (content, truncated) = execute(sandbox, call).await;
|
||||
|
||||
info!(
|
||||
iteration,
|
||||
tool = call.name(),
|
||||
arguments = %excerpt(call.arguments_json(), MAX_LOGGED_ARGUMENTS),
|
||||
output_bytes = content.len(),
|
||||
truncated,
|
||||
// Les sauts de ligne casseraient la lisibilité d'une ligne de log.
|
||||
output = %excerpt(&content, MAX_LOGGED_OUTPUT).replace('\n', " "),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"Tool call finished"
|
||||
);
|
||||
|
||||
messages.push(Message::tool_response(call.id(), content));
|
||||
}
|
||||
}
|
||||
|
||||
warn!(max_iterations, "Agent reached the iteration limit");
|
||||
anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})")
|
||||
warn!(
|
||||
max_iterations,
|
||||
"Agent used its last turn without a valid answer"
|
||||
);
|
||||
|
||||
match last_rejection {
|
||||
Some(error) => anyhow::bail!(
|
||||
"agent exceeded the maximum number of iterations ({max_iterations}), \
|
||||
last answer rejected: {error}"
|
||||
),
|
||||
None => anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> String {
|
||||
/// Runs a tool call and returns its result for the model, along with whether the
|
||||
/// result had to be truncated.
|
||||
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> (String, bool) {
|
||||
let args = match parse_args(call) {
|
||||
Ok(args) => args,
|
||||
Err(err) => return format!("error: {err}"),
|
||||
Err(err) => return cap_output(format!("error: {err}")),
|
||||
};
|
||||
|
||||
match tools::dispatch(sandbox, call.name(), &args).await {
|
||||
Ok(output) => output,
|
||||
Err(err) => format!("error: {err}"),
|
||||
Ok(output) => cap_output(output),
|
||||
Err(err) => cap_output(format!("error: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds [`MAX_TOOL_OUTPUT_BYTES`] of a tool result, telling the model what was
|
||||
/// dropped so that it can narrow its request.
|
||||
fn cap_output(mut output: String) -> (String, bool) {
|
||||
let total = output.len();
|
||||
|
||||
if !truncate_bytes(&mut output, MAX_TOOL_OUTPUT_BYTES) {
|
||||
return (output, false);
|
||||
}
|
||||
|
||||
let kept = output.len();
|
||||
output.push_str(&format!(
|
||||
"\n… output truncated: {total} bytes in total, the first {kept} are shown. \
|
||||
Narrow the request (path, pattern or line range) to see the rest."
|
||||
));
|
||||
|
||||
(output, true)
|
||||
}
|
||||
|
||||
fn parse_args(call: &ToolCall) -> anyhow::Result<Value> {
|
||||
let raw = call.arguments_json().trim();
|
||||
if raw.is_empty() {
|
||||
@@ -134,4 +288,29 @@ mod tests {
|
||||
let call = tool_call("ls", "not json");
|
||||
assert!(parse_args(&call).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_tool_output_is_kept_as_is() {
|
||||
let (content, truncated) = cap_output(String::from("src/main.rs"));
|
||||
|
||||
assert_eq!(content, "src/main.rs");
|
||||
assert!(!truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_tool_output_is_truncated_and_told_to_the_model() {
|
||||
let (content, truncated) = cap_output("a".repeat(MAX_TOOL_OUTPUT_BYTES + 1));
|
||||
|
||||
assert!(truncated);
|
||||
assert!(content.starts_with(&"a".repeat(MAX_TOOL_OUTPUT_BYTES)));
|
||||
assert!(content.contains("output truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_tool_output_stays_valid_utf8() {
|
||||
let (content, truncated) = cap_output("é".repeat(MAX_TOOL_OUTPUT_BYTES));
|
||||
|
||||
assert!(truncated);
|
||||
assert!(content.starts_with('é'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Small text helpers shared by the modules that log what the model answered and
|
||||
//! bound what the tools return to it.
|
||||
|
||||
/// First `limit` characters of `text`, for logs.
|
||||
///
|
||||
/// Cuts on character boundaries, so the excerpt stays valid UTF-8, and marks a
|
||||
/// truncation with an ellipsis.
|
||||
pub fn excerpt(text: &str, limit: usize) -> String {
|
||||
let mut chars = text.chars();
|
||||
let excerpt = chars.by_ref().take(limit).collect::<String>();
|
||||
|
||||
if chars.next().is_some() {
|
||||
return format!("{excerpt}…");
|
||||
}
|
||||
|
||||
excerpt
|
||||
}
|
||||
|
||||
/// Truncates `text` in place to at most `limit` bytes, cutting on a character
|
||||
/// boundary so the result stays valid UTF-8.
|
||||
///
|
||||
/// Returns `true` when something was dropped.
|
||||
pub fn truncate_bytes(text: &mut String, limit: usize) -> bool {
|
||||
if text.len() <= limit {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut end = limit;
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
text.truncate(end);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_short_text_is_kept_as_is() {
|
||||
assert_eq!(excerpt("abc", 3), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_text_is_truncated_and_marked() {
|
||||
assert_eq!(excerpt("abcdef", 3), "abc…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_cuts_on_character_boundaries() {
|
||||
assert_eq!(excerpt("ééé", 2), "éé…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_text_is_not_truncated() {
|
||||
let mut text = String::from("abc");
|
||||
|
||||
assert!(!truncate_bytes(&mut text, 3));
|
||||
assert_eq!(text, "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_text_is_truncated_within_the_limit() {
|
||||
let mut text = String::from("abcdef");
|
||||
|
||||
assert!(truncate_bytes(&mut text, 4));
|
||||
assert_eq!(text, "abcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_truncation_never_splits_a_character() {
|
||||
let mut text = String::from("ééé");
|
||||
|
||||
// The limit falls in the middle of the second `é`: it is dropped.
|
||||
assert!(truncate_bytes(&mut text, 3));
|
||||
assert_eq!(text, "é");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user